diff --git a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts index 41c45768e73..d20c676bb20 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts @@ -1,4 +1,9 @@ import path from 'node:path' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { + makeSnapshot, + resetWebSessionTabsSyncTestState +} from '@/runtime/web-session-tabs-sync-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' @@ -274,6 +279,47 @@ describe('resume across the mirror handle gap', () => { expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1) }) + it('holds a slept binding retained through a pending snapshot without replacing it', () => { + resetWebSessionTabsSyncTestState() + const worktree = makeRuntimeOwnedWorktree() + seedMirroredWorkspace(worktree) + const paneKey = seedActiveSleepingRecord(worktree.id) + markHostSessionMirrorHydrated(RUNTIME_ENV_ID) + const snapshot = makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-1::${LEAF_ID}`, + title: 'Claude', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'pending-handle', + terminal: null + } + ], + { worktree: worktree.id } + ) + useAppStore.setState( + applyWebSessionTabsSnapshot(useAppStore.getState(), snapshot, RUNTIME_ENV_ID, Date.now()) + ) + + expect( + useAppStore.getState().terminalLayoutsByTabId[WEB_TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] + ).toBe('remote:env-handle-gap@@term_1') + expect(useAppStore.getState().ptyIdsByTabId[WEB_TAB_ID] ?? []).toEqual([]) + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1) + vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0) + vi.advanceTimersByTime(1) + // The deadline ends the wait, but an unverifiable binding never authorizes a replacement. + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0) + resetWebSessionTabsSyncTestState() + }) + it('does not resume a published host row without a PTY binding', () => { const worktree = makeRuntimeOwnedWorktree() seedMirroredWorkspace(worktree) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-sleep-activity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-sleep-activity.test.ts new file mode 100644 index 00000000000..dd7065bde6f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-sleep-activity.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { isInactiveWorkspace } from '../lib/worktree-activity-state' +import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '../lib/worktree-sleep-intent' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + SECOND_LEAF_ID, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) + +const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1') +const PTY_ID = `remote:${ENV}@@terminal-1` +const FOLDER = 'folder:folder-1' + +function sleptState(worktreeId = WT, ptyId = PTY_ID) { + return makeState({ + activeWorktreeId: 'another-workspace', + tabsByWorktree: { + [worktreeId]: [ + { + id: TAB_ID, + ptyId, + worktreeId, + title: 'shell', + defaultTitle: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + ptyIdsByTabId: { [TAB_ID]: [] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + } + } + }) +} + +function pendingSnapshot(worktreeId = WT) { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'pending-handle', + terminal: null + } + ], + { worktree: worktreeId } + ) +} + +function inactive(state: ReturnType, worktreeId = WT) { + return isInactiveWorkspace(worktreeId, state.tabsByWorktree, state.ptyIdsByTabId, {}, new Set()) +} + +describe('sleep activity during host terminal mirroring', () => { + beforeEach(resetWebSessionTabsSyncTestState) + afterEach(() => { + clearWorktreeSleepIntent(WT) + clearWorktreeSleepIntent(FOLDER) + }) + + it.each([WT, FOLDER])('keeps %s asleep while retaining its wake binding', (worktreeId) => { + const state = sleptState(worktreeId) + markWorktreeSleepIntent(worktreeId) + expect(inactive(state, worktreeId)).toBe(true) + + const next = { + ...state, + ...applyWebSessionTabsSnapshot(state, pendingSnapshot(worktreeId), ENV, NOW + 1) + } + + expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID }) + expect(next.tabsByWorktree[worktreeId]?.[0]?.ptyId).toBe(PTY_ID) + expect(next.ptyIdsByTabId[TAB_ID] ?? []).toEqual([]) + expect(inactive(next, worktreeId)).toBe(true) + expect(next.activeWorktreeId).toBe('another-workspace') + }) + + it('does not turn a restored wake binding into live activity without a sleep marker', () => { + const state = sleptState() + const next = { + ...state, + ...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1) + } + expect(inactive(next)).toBe(true) + }) + + it('keeps a previously live binding during an ordinary reconnect', () => { + const state = sleptState() + state.ptyIdsByTabId[TAB_ID] = [PTY_ID] + const next = { + ...state, + ...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1) + } + expect(next.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID]) + expect(inactive(next)).toBe(false) + }) + + it('publishes a ready replacement after explicit wake', () => { + const state = sleptState() + markWorktreeSleepIntent(WT) + clearWorktreeSleepIntent(WT) + const snapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ]) + const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) } + expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`]) + expect(inactive(next)).toBe(false) + }) + + it('counts only ready panes when a slept split sibling is still pending', () => { + const state = sleptState() + const snapshot = pendingSnapshot() + snapshot.tabs.push({ + type: 'terminal', + id: `host-tab-1::${SECOND_LEAF_ID}`, + title: 'new shell', + parentTabId: 'host-tab-1', + leafId: SECOND_LEAF_ID, + isActive: false, + status: 'ready', + terminal: 'terminal-2' + }) + const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) } + expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID]).toBe(PTY_ID) + expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`]) + expect(inactive(next)).toBe(false) + }) + + it('keeps the wake binding inactive across repeated host updates', () => { + const state = sleptState() + const snapshot = pendingSnapshot() + const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) } + const updatedSnapshot = { ...snapshot, snapshotVersion: 2 } + const updated = { + ...next, + ...applyWebSessionTabsSnapshot(next, updatedSnapshot, ENV, NOW + 2) + } + expect(inactive(updated)).toBe(true) + expect(updated.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID }) + }) + + it('keeps local-mode pending wake bindings out of live activity', () => { + const state = sleptState(WT, 'local-pty') + const next = { + ...state, + ...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1, { + terminalPtyMode: 'local' + }) + } + expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'local-pty' }) + expect(inactive(next)).toBe(true) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts index 65166ab8e79..c14aa283218 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -155,6 +155,7 @@ export function prepareWebSessionTabsSnapshotBase( environmentId, existingTerminalById, state.terminalLayoutsByTabId, + state.ptyIdsByTabId, retainedTerminalTabs.length, now, callerFocusIntentTab?.type === 'terminal' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts index b62542a86cd..901c59ef43b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts @@ -55,7 +55,15 @@ function rebuild(existing?: Partial): TerminalTab { const existingById = new Map( existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : [] ) - const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000) + const [mirrored] = buildMirroredTerminalTabs( + snapshot(), + ENVIRONMENT, + existingById, + {}, + {}, + 0, + 1_000 + ) return mirrored!.tab } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index 7ae2941cbaa..1a26b2b793a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -57,6 +57,7 @@ export function buildMirroredTerminalTabs( environmentId: string, existingById: ReadonlyMap, existingLayoutsByTabId: Readonly>, + existingLivePtyIdsByTabId: Readonly>, sortOffset: number, now: number, focusTarget?: { parentTabId: string; leafId: string }, @@ -101,7 +102,14 @@ export function buildMirroredTerminalTabs( chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) ).snapshot const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {}) - const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId) + // Wake bindings survive Sleep; only ready surfaces or already-live bindings count as activity. + const livePtyIds = new Set([ + ...Object.values(freshPtyIdsByLeafId), + ...(existingLivePtyIdsByTabId[localTabId] ?? []) + ]) + const ptyIds = layoutPtyEntries + .map(([, ptyId]) => ptyId) + .filter((ptyId) => livePtyIds.has(ptyId)) let retainedSurfaceByPrunedLeafId: Map | undefined if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) { const retainedLeafIdByPtyId = new Map(