fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server (#25110)

* fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server

* fix(orcad): a v1.4.218 profile focused on the SSH worktree converts, and a refusal names what blocks it

The debounced session writer never patched activeWorkspaceKey or activeWorkspaceExecutionHostId,
so the first focus stayed on disk. The all-dependency census also counted global focus copies in
every non-source partition.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-03 19:16:44 -07:00
committed by GitHub
co-authored by m4air
parent 24bce4581a
commit 07a8f79c25
15 changed files with 499 additions and 26 deletions
@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import type { ExecutionHostId } from '../../../shared/execution-host'
import {
ORCAD_MIGRATION_MANIFEST_VERSION,
type OrcadMigrationManifest
} from '../../../shared/orcad-migration-manifest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import type { SshTarget } from '../../../shared/ssh-types'
import type { Tab } from '../../../shared/tab-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget'
import {
collectOrcadMigrationSourceDependencyCensus,
collectOrcadMigrationUntransferredDependencyCensus
} from './orcad-source-dependency-census'
import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state'
import {
assertOrcadMigrationSourceWorkspaceSessionRetired,
retireOrcadMigrationSourceWorkspaceSession
} from './orcad-source-workspace-session-retirement'
const TARGET: SshTarget = { id: 'ssh-prod', label: 'Prod', host: 'prod', port: 22, username: 'u' }
const SSH_HOST = `ssh:${TARGET.id}` as const
const REPO: Repo = {
id: 'repo-1',
path: '/srv/repo',
displayName: 'Repository',
badgeColor: '#737373',
addedAt: 1,
connectionId: TARGET.id,
executionHostId: SSH_HOST
}
const WORKTREE = `${REPO.id}::/srv/repo`
function manifest(destinationEnvironmentId?: string): OrcadMigrationManifest {
return {
version: ORCAD_MIGRATION_MANIFEST_VERSION,
migrationId: 'migration-1',
createdAt: '2026-10-03T12:00:00.000Z',
source: { sshTargetId: TARGET.id, sshTargetGeneration: null, targetLabel: TARGET.label },
payload: { repositories: [REPO], projectGroups: [], folderWorkspaces: [] },
...(destinationEnvironmentId ? { destinationEnvironmentId } : {}),
manifestSha256: 'a'.repeat(64)
}
}
function editorTab(executionHostId?: ExecutionHostId): Tab {
return {
id: 'tab-1',
entityId: '/srv/repo/README.md',
groupId: 'group-1',
worktreeId: WORKTREE,
...(executionHostId ? { executionHostId } : {}),
contentType: 'editor',
label: 'README.md',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function sourceState(): PersistedState {
const state = getDefaultPersistedState('/home/test')
state.sshTargets = [TARGET]
state.repos = [REPO]
return state
}
function focusLocalOnSourceWorktree(state: PersistedState): void {
state.workspaceSession = {
...state.workspaceSession,
activeRepoId: REPO.id,
activeWorktreeId: WORKTREE,
activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE),
activeWorkspaceExecutionHostId: SSH_HOST,
activeTabId: 'tab-1'
}
}
const sessionCount = (state: PersistedState): number =>
collectOrcadMigrationUntransferredDependencyCensus(state, manifest('env-1')).counts[
'workspace-session'
]
describe('orcad migration census and client focus', () => {
it('never blocks a move on client focus aimed at a migrating worktree', () => {
const state = sourceState()
focusLocalOnSourceWorktree(state)
expect(sessionCount(state)).toBe(0)
})
it('passes a v1.4.218 profile quit while focused on the source worktree', () => {
// v1.4.218 copied the global focus fields into 'local' and into every host partition it wrote.
const state = sourceState()
focusLocalOnSourceWorktree(state)
const focusCopy = {
...state.workspaceSession,
unifiedTabs: {},
tabsByWorktree: {}
}
state.workspaceSessionsByHostId = {
[SSH_HOST]: { ...focusCopy, unifiedTabs: { [WORKTREE]: [editorTab('local')] } },
'ssh:other-a': { ...focusCopy },
'ssh:other-b': { ...focusCopy }
}
// The same profile with focus only in the source partition: what the census may count.
const sourceOnly = structuredClone(state)
const noFocus = {
activeRepoId: null,
activeWorktreeId: null,
activeWorkspaceKey: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null
}
Object.assign(sourceOnly.workspaceSession, noFocus)
Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-a']!, noFocus)
Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-b']!, noFocus)
expect(sessionCount(state)).toBe(0)
expect(
collectOrcadMigrationSourceDependencyCensus(state, manifest('env-1')).counts[
'workspace-session'
]
).toBe(
collectOrcadMigrationSourceDependencyCensus(sourceOnly, manifest('env-1')).counts[
'workspace-session'
]
)
})
it('carries no client focus into the destination session', () => {
const state = sourceState()
focusLocalOnSourceWorktree(state)
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] }
const session = collectOrcadMigrationSourceDormantState(state, manifest().source, {
repositories: [REPO],
projectGroups: [],
folderWorkspaces: []
}).payload.workspaceSession
expect(session?.unifiedTabs?.[WORKTREE]).toHaveLength(1)
expect(session?.activeWorktreeId ?? null).toBeNull()
expect(session?.activeWorkspaceKey ?? null).toBeNull()
})
it('remaps client focus to the same worktree in the managed environment', () => {
const state = sourceState()
focusLocalOnSourceWorktree(state)
retargetOrcadSourceClientFocus(state, manifest('env-1'))
expect(state.workspaceSession).toMatchObject({
activeRepoId: REPO.id,
activeWorktreeId: WORKTREE,
activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE),
activeWorkspaceExecutionHostId: 'runtime:env-1',
activeTabId: 'tab-1'
})
})
it('clears client focus the managed environment cannot resolve', () => {
const state = sourceState()
focusLocalOnSourceWorktree(state)
const unmoved = sourceState()
focusLocalOnSourceWorktree(unmoved)
unmoved.workspaceSession.activeWorktreeId = 'other-repo::/srv/other'
unmoved.workspaceSession.activeWorkspaceKey = worktreeWorkspaceKey('other-repo::/srv/other')
retargetOrcadSourceClientFocus(state, manifest())
retargetOrcadSourceClientFocus(unmoved, manifest('env-1'))
for (const session of [state.workspaceSession, unmoved.workspaceSession]) {
expect(session).toMatchObject({
activeRepoId: null,
activeWorktreeId: null,
activeWorkspaceKey: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null
})
}
})
it('keeps remapped focus through session retirement, which then verifies clean', () => {
const state = sourceState()
focusLocalOnSourceWorktree(state)
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] }
const moved = manifest('env-1')
moved.payload.dormantState = collectOrcadMigrationSourceDormantState(
state,
moved.source,
moved.payload
).payload
retargetOrcadSourceClientFocus(state, moved)
retireOrcadMigrationSourceWorkspaceSession(state, moved)
expect(state.workspaceSession.unifiedTabs?.[WORKTREE]).toBeUndefined()
expect(state.workspaceSession).toMatchObject({
activeWorktreeId: WORKTREE,
activeWorkspaceExecutionHostId: 'runtime:env-1'
})
expect(() => assertOrcadMigrationSourceWorkspaceSessionRetired(state, moved)).not.toThrow()
})
it("counts a 'local'-stamped tab in a migrating SSH worktree as the SSH host's", () => {
const local = sourceState()
local.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('local')] }
const partitioned = sourceState()
partitioned.workspaceSessionsByHostId = {
[SSH_HOST]: {
...partitioned.workspaceSession,
unifiedTabs: { [WORKTREE]: [editorTab('local')] }
}
}
expect(sessionCount(local)).toBe(0)
expect(sessionCount(partitioned)).toBe(0)
})
it('still blocks a tab another host owns inside a migrating worktree', () => {
const state = sourceState()
state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('ssh:other')] }
expect(sessionCount(state)).toBe(1)
})
})
@@ -0,0 +1,74 @@
import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host'
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { isWorkspaceKey } from '../../../shared/workspace-scope'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
createOrcadMigrationSourceScope,
orcadMigrationOwnerMatchesScope,
unqualifyOrcadMigrationOwnerKey,
type OrcadMigrationSourceScope
} from './orcad-source-scope'
/**
* Client focus on a moved worktree follows it to the managed environment, or is cleared when the
* destination cannot resolve it. The census never blocks on this focus, so retirement owns it.
*/
export function retargetOrcadSourceClientFocus(
state: PersistedState,
manifest: OrcadMigrationManifest
): void {
const scope = createOrcadMigrationSourceScope({
source: manifest.source,
catalog: manifest.payload
})
const destinationHostId = manifest.destinationEnvironmentId
? toRuntimeExecutionHostId(manifest.destinationEnvironmentId)
: null
retargetSessionFocus(state.workspaceSession, scope, destinationHostId)
for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) {
if (session && hostId !== scope.hostId) {
retargetSessionFocus(session, scope, destinationHostId)
}
}
}
/** Focus already on a runtime host names that host's workspace, never the SSH source's. */
export function isOrcadRuntimeHostFocus(session: WorkspaceSessionState): boolean {
return parseExecutionHostId(session.activeWorkspaceExecutionHostId)?.kind === 'runtime'
}
function retargetSessionFocus(
session: WorkspaceSessionState,
scope: OrcadMigrationSourceScope,
destinationHostId: `runtime:${string}` | null
): void {
const owns = (value: string | null | undefined): value is string =>
orcadMigrationOwnerMatchesScope(value, scope)
const focusHost = session.activeWorkspaceExecutionHostId
const aimedAtSource =
focusHost === scope.hostId ||
(!focusHost && (owns(session.activeWorktreeId) || owns(session.activeWorkspaceKey)))
if (!aimedAtSource) {
return
}
const worktreeId = owns(session.activeWorktreeId)
? unqualifyOrcadMigrationOwnerKey(session.activeWorktreeId)
: null
const workspaceKey = owns(session.activeWorkspaceKey)
? unqualifyOrcadMigrationOwnerKey(session.activeWorkspaceKey)
: null
if (destinationHostId && (worktreeId || workspaceKey)) {
session.activeWorktreeId = worktreeId
session.activeWorkspaceKey = workspaceKey && isWorkspaceKey(workspaceKey) ? workspaceKey : null
session.activeWorkspaceExecutionHostId = destinationHostId
return
}
session.activeWorktreeId = null
session.activeWorkspaceKey = null
session.activeWorkspaceExecutionHostId = null
session.activeTabId = null
if (session.activeRepoId && scope.repoIds.has(session.activeRepoId)) {
session.activeRepoId = null
}
}
@@ -17,6 +17,7 @@ import {
retireOrcadMigrationSourceDormantState
} from './orcad-source-dormant-retirement'
import { retireOrcadSourceReconnectHint } from './orcad-source-workspace-session-retirement'
import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget'
const orcadSourceRetirementContext = Symbol('OrcadSourceRetirementPersistence')
type OrcadSourceRetirementContext = {
@@ -42,6 +43,7 @@ export class OrcadSourceRetirementPersistence {
const context = this[orcadSourceRetirementContext]
const state = context.runtime.state
retireOrcadSourceCatalogState(state, manifest)
retargetOrcadSourceClientFocus(state, manifest)
retireOrcadMigrationSourceDormantState(state, manifest)
retireOrcadSourceReconnectHint(state, manifest.source.sshTargetId)
syncProjectHostSetupCompatibilityState(context.repos)
@@ -110,6 +110,10 @@ function inspectSession(
}
}
result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length
if (!sourceHostPartition) {
// Global fields here are copies of client focus and history, not this host's state.
return result
}
result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0
result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0
result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0
@@ -79,22 +79,15 @@ export function countUnsupportedSessionState(
shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId)
).length
count +=
sourceHostPartition &&
session.activeRepoId &&
owns(session.activeRepoId) &&
!(sourceHostPartition && scope.repoIds.has(session.activeRepoId))
!scope.repoIds.has(session.activeRepoId)
? 1
: 0
// Why not in the source partition: there focus is a copy of the client-wide focus. An in-scope
// copy is carried; one aimed at another host's workspace is not host state and is never carried.
count +=
!sourceHostPartition && session.activeWorktreeId && owns(session.activeWorktreeId) ? 1 : 0
count +=
!sourceHostPartition && session.activeWorkspaceKey && owns(session.activeWorkspaceKey) ? 1 : 0
count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0
// Focus outside the source partition is client focus, not host state: retirement retargets it.
// activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and
// the remote work it can stand for (tab PTYs, remote session ids, leases) is counted on its own.
count +=
session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0
for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) {
if (owns(ownerKey)) {
count += files.filter(
@@ -112,7 +105,8 @@ export function countUnsupportedSessionState(
for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) {
if (owns(ownerKey)) {
count += tabs.filter(
(tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId
(tab) =>
tab.executionHostId !== undefined && !isOrcadSourceTabHost(tab.executionHostId, scope)
).length
}
}
@@ -206,6 +200,11 @@ export function projectDormantSessionFocus(
}
}
/** Older builds stamped 'local' on tabs created in an SSH worktree; its owner key proves the host. */
function isOrcadSourceTabHost(executionHostId: string, scope: OrcadMigrationSourceScope): boolean {
return executionHostId === scope.hostId || executionHostId === LOCAL_EXECUTION_HOST_ID
}
export function projectSessionToDestination(
session: WorkspaceSessionState,
scope: OrcadMigrationSourceScope
@@ -10,6 +10,7 @@ import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragme
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session'
import { isOrcadRuntimeHostFocus } from './orcad-source-client-focus-retarget'
export function retireOrcadMigrationSourceWorkspaceSession(
state: PersistedState,
@@ -83,17 +84,30 @@ export function removeOwnedSessionState(
)
}
}
// Focus retargeted to the destination names the same ids on the managed host; keep it.
const retargetedFocus = isOrcadRuntimeHostFocus(session)
? {
activeRepoId: session.activeRepoId,
activeWorktreeId: session.activeWorktreeId,
activeWorkspaceKey: session.activeWorkspaceKey,
activeTabId: session.activeTabId
}
: null
const next = removeWorkspaceSessionOwners(session, ownerKeys) ?? session
// Selection-only sessions and canonical workspace keys need the same scoped retirement.
const retired = next === session ? structuredClone(session) : next
if (retired.activeRepoId && scope.repoIds.has(retired.activeRepoId)) {
retired.activeRepoId = null
}
if (orcadMigrationOwnerMatchesScope(retired.activeWorktreeId, scope)) {
retired.activeWorktreeId = null
}
if (orcadMigrationOwnerMatchesScope(retired.activeWorkspaceKey, scope)) {
retired.activeWorkspaceKey = null
if (retargetedFocus) {
Object.assign(retired, retargetedFocus)
} else {
if (retired.activeRepoId && scope.repoIds.has(retired.activeRepoId)) {
retired.activeRepoId = null
}
if (orcadMigrationOwnerMatchesScope(retired.activeWorktreeId, scope)) {
retired.activeWorktreeId = null
}
if (orcadMigrationOwnerMatchesScope(retired.activeWorkspaceKey, scope)) {
retired.activeWorkspaceKey = null
}
}
if (retired.activeWorkspaceExecutionHostId === scope.hostId) {
retired.activeWorkspaceExecutionHostId = null
@@ -80,6 +80,11 @@ export function collectOrcadMigrationSourceWorkspaceSession(
}
: null
})
if (!sourceHostPartition) {
// Client focus is not host state; retirement retargets it instead of the destination carrying it.
fragment.activeWorktreeId = null
delete fragment.activeWorkspaceKey
}
// A shutdown marker is only a reconnect hint. Once the source has no live
// PTY authority for that worktree, carrying it would make the destination
// try to resurrect a process that no longer exists.
+2 -1
View File
@@ -34,6 +34,7 @@ import {
} from './orcad-migration-terminal-gate'
import { currentOrcadSourceFingerprint } from './orcad-retained-source'
import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
export type OrcadDeltaMoveArgs = {
userDataPath: string
@@ -58,7 +59,7 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise<Orcad
}
if (plan.blockers.length > 0) {
return {
...refuse('orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'),
...refuse('orcad_migration_preflight_blocked', orcadMigrationRefusalReason(plan.blockers)),
blockers: plan.blockers
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
describe('orcadMigrationRefusalReason', () => {
it('names each blocking kind once, in plain words', () => {
expect(
orcadMigrationRefusalReason([
{
code: 'orcad_migration_direct_ssh_repositories',
category: 'drainable-static-state',
repositories: []
},
{
code: 'orcad_migration_dependent_state',
category: 'client-owned-state',
dependencies: [
{ kind: 'workspace-session', count: 7 },
{ kind: 'worktree-lineage', count: 1 },
{ kind: 'workspace-lineage', count: 1 },
{ kind: 'automation', count: 2 }
]
},
{
code: 'orcad_migration_direct_ssh_terminal_leases',
category: 'live-or-unverifiable',
terminalLeases: []
}
])
).toBe(
'This SSH host cannot move yet: saved tabs and panes, workspace history, automations, terminals still running.'
)
})
it('keeps the bare refusal when nothing that blocks is named', () => {
expect(orcadMigrationRefusalReason([])).toBe('This SSH host cannot move yet.')
})
})
@@ -0,0 +1,50 @@
import type {
OrcadMigrationBlocker,
OrcadMigrationDependencyKind
} from '../../shared/orcad-migration-preflight'
const DEPENDENCY_WORDS: Record<OrcadMigrationDependencyKind, string> = {
'saved-port-forward': 'saved port forwards',
'terminal-lease': 'terminals still running',
'terminal-recovery': 'terminals waiting to reconnect',
'workspace-session': 'saved tabs and panes',
'worktree-metadata': 'saved workspace details',
'worktree-lineage': 'workspace history',
'workspace-lineage': 'workspace history',
'sparse-preset': 'sparse checkout presets',
'retired-worktree-name': 'retired workspace names',
automation: 'automations',
'automation-run': 'automation runs',
'mobile-tab-selection': 'mobile tab selections',
'ui-routing': 'sidebar settings'
}
function blockerWords(blocker: OrcadMigrationBlocker): string[] {
switch (blocker.code) {
case 'orcad_migration_target_not_found':
return ['the host is no longer saved']
case 'orcad_migration_target_owned':
return ['another server holds it']
case 'orcad_migration_owner_unrecorded':
return ['its server record is missing']
case 'orcad_migration_direct_ssh_terminal_leases':
return ['terminals still running']
case 'orcad_migration_dependent_state':
return blocker.dependencies.map(({ kind }) => DEPENDENCY_WORDS[kind])
case 'orcad_migration_dependency_unverifiable':
return ['saved state Orca could not read']
case 'orcad_migration_direct_ssh_repositories':
case 'orcad_migration_direct_ssh_folder_workspaces':
case 'orcad_migration_saved_port_forwards':
// These move with the host or stay behind; they never refuse a conversion.
return []
}
}
/** The refusal names what blocks, in plain words; the status line shows only this text. */
export function orcadMigrationRefusalReason(blockers: readonly OrcadMigrationBlocker[]): string {
const words = [...new Set(blockers.flatMap(blockerWords))]
return words.length > 0
? `This SSH host cannot move yet: ${words.join(', ')}.`
: 'This SSH host cannot move yet.'
}
+6 -1
View File
@@ -29,6 +29,7 @@ import {
preflightOrcadMigrationExport,
type OrcadMigrationPreflightStore
} from './ssh-target-orcad-preflight'
import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason'
export type OrcadMigrationFenceState =
| { state: 'none' }
@@ -115,7 +116,11 @@ export async function fenceOrcadMigrationSource(args: {
const preflight = preflightOrcadMigrationExport(args.store, target.id)
if (!preflight.claimable) {
return {
...refuse('live', 'orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'),
...refuse(
'live',
'orcad_migration_preflight_blocked',
orcadMigrationRefusalReason(preflight.blockers)
),
blockers: preflight.blockers
}
}
@@ -11,6 +11,8 @@ import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import { dedupePaletteWorktrees } from './palette-repo-resolution'
import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
export function getPaletteOwnershipWorktreeIds(
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
@@ -40,15 +42,21 @@ export function findAmbiguousWorktreeIds(
}
export function getActiveExecutionHostIdForWorktree(
state: {
state: WorktreeRuntimeOwnerState & {
activeWorktreeId: string | null
activeWorkspaceExecutionHostId?: ExecutionHostId | null
},
worktreeId: string
): ExecutionHostId | undefined {
return state.activeWorktreeId === worktreeId
? (state.activeWorkspaceExecutionHostId ?? LOCAL_EXECUTION_HOST_ID)
: undefined
if (state.activeWorktreeId !== worktreeId) {
return undefined
}
// Why: most activations pass no host; a bare 'local' there stamped SSH worktrees' tabs as local.
return (
state.activeWorkspaceExecutionHostId ??
getResolvedExecutionHostIdForWorktree(state, worktreeId) ??
LOCAL_EXECUTION_HOST_ID
)
}
export function isUnifiedTabOwnedByWorktree(
@@ -90,6 +90,23 @@ describe('buildWorkspaceSessionPatch', () => {
expect(patch).toEqual({ activeTabId: 'tab-2' })
})
it('writes a changed workspace key and host so a refocus replaces the persisted ones', () => {
const patch = buildWorkspaceSessionPatch(
createSnapshot({
activeWorktreeId: 'wt-2',
activeWorkspaceKey: 'worktree:wt-2',
activeWorkspaceExecutionHostId: null
}),
['activeWorktreeId', 'activeWorkspaceKey', 'activeWorkspaceExecutionHostId']
)
expect(patch).toEqual({
activeWorktreeId: 'wt-2',
activeWorkspaceKey: 'worktree:wt-2',
activeWorkspaceExecutionHostId: null
})
})
it('derives only editor session keys for open file changes', () => {
const patch = buildWorkspaceSessionPatch(createSnapshot(), ['openFiles'])
@@ -65,6 +65,13 @@ export function buildWorkspaceSessionPatch(
if (changed.has('activeWorktreeId')) {
patch.activeWorktreeId = snapshot.activeWorktreeId
}
// Why: without these the debounced writer kept the first focus's key and host forever.
if (changed.has('activeWorkspaceKey')) {
patch.activeWorkspaceKey = snapshot.activeWorkspaceKey
}
if (changed.has('activeWorkspaceExecutionHostId')) {
patch.activeWorkspaceExecutionHostId = snapshot.activeWorkspaceExecutionHostId ?? null
}
if (changed.has('activeTabId')) {
patch.activeTabId = snapshot.activeTabId
}
@@ -4,7 +4,13 @@ import { getDefaultUIState } from '../../../../shared/constants'
import { buildMobileSessionTabSnapshots } from '../../runtime/sync-runtime-graph'
import { closeMobileSessionTabInStore } from '../../runtime/mobile-session-tab-close'
import { createTabsSliceMockApi } from './tabs-slice-test-harness'
import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers'
import {
createTestStore,
makeOpenFile,
makeTabGroup,
makeUnifiedTab,
makeWorktree
} from './store-test-helpers'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -74,6 +80,19 @@ describe('TabsSlice', () => {
expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('local')
})
it("stamps an SSH worktree's own host when activation passed none", () => {
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:box' })] },
activeWorktreeId: WT,
activeWorkspaceExecutionHostId: null
})
expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('ssh:box')
})
it('activates the newly created tab', () => {
const tab1 = store.getState().createUnifiedTab(WT, 'terminal')
const tab2 = store.getState().createUnifiedTab(WT, 'terminal')