From 60fdd11114093ce0fc33095a0045cb60bd742480 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 15 May 2026 12:10:06 -0700 Subject: [PATCH] Migrate agent pane identity to stable leaf ids (#1909) Co-authored-by: Orca --- .../migration-unsupported-pty-state.ts | 47 ++ src/main/agent-hooks/server.test.ts | 87 ++- src/main/agent-hooks/server.ts | 30 +- src/main/daemon/daemon-pty-adapter.ts | 9 +- src/main/index.ts | 14 + src/main/ipc/agent-hooks.test.ts | 11 +- src/main/ipc/agent-hooks.ts | 15 +- src/main/ipc/pty.test.ts | 166 +++- src/main/ipc/pty.ts | 182 ++++- src/main/persistence.test.ts | 723 +++++++++++++++--- src/main/persistence.ts | 707 ++++++++++++++++- src/main/runtime/orca-runtime.test.ts | 163 +++- src/main/runtime/orca-runtime.ts | 62 +- src/main/runtime/rpc/methods/terminal.ts | 8 +- ...ay-session-agent-hooks.integration.test.ts | 18 +- .../window/attach-main-window-services.ts | 5 +- src/preload/api-types.ts | 11 +- src/preload/index.ts | 22 +- src/relay/agent-hook-integration.test.ts | 11 +- src/relay/agent-hook-server.test.ts | 14 +- src/renderer/src/components/Terminal.tsx | 5 +- .../src/components/WorktreeJumpPalette.tsx | 18 +- .../activity/ActivityPrototypePage.test.ts | 96 ++- .../activity/ActivityPrototypePage.tsx | 273 +++++-- .../activity/ActivityTitlebarControls.tsx | 7 + .../activity/activity-terminal-portal.ts | 42 +- .../components/dashboard/useDashboardData.ts | 55 +- .../components/dashboard/useRetainedAgents.ts | 7 +- .../dashboard/useRetainedAgentsSync.test.ts | 37 +- .../right-sidebar/SourceControl.tsx | 4 +- .../src/components/sidebar/CacheTimer.tsx | 2 +- .../src/components/sidebar/SidebarNav.tsx | 10 + .../components/sidebar/WorktreeCardAgents.tsx | 34 +- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../sidebar/smart-attention.test.ts | 51 +- .../src/components/sidebar/smart-attention.ts | 123 ++- .../src/components/sidebar/smart-sort.test.ts | 95 +-- .../src/components/sidebar/smart-sort.ts | 15 +- .../sidebar/use-worktree-activity-status.ts | 20 +- .../sidebar/useWorktreeAgentRows.test.ts | 21 +- .../sidebar/useWorktreeAgentRows.ts | 29 +- .../components/sidebar/visible-worktrees.ts | 4 +- .../status-bar/ResourceUsageStatusSegment.tsx | 16 +- .../status-bar/mergeSnapshotAndSessions.ts | 20 +- .../components/terminal-pane/TerminalPane.tsx | 72 +- .../TerminalPaneOverlayLayer.tsx | 9 +- .../focus-terminal-pane-event.ts | 39 + .../layout-serialization.test.ts | 141 +++- .../terminal-pane/layout-serialization.ts | 96 +-- .../terminal-pane/pty-connection.test.ts | 62 +- .../terminal-pane/pty-connection.ts | 32 +- .../terminal-pane/pty-transport.test.ts | 4 +- .../remote-runtime-pty-transport.ts | 2 + .../terminal-pane/stale-agent-row.ts | 20 + .../terminal-pane/terminal-layout-leaf-ids.ts | 173 +++++ .../terminal-shutdown-layout-capture.test.ts | 31 +- .../terminal-shutdown-layout-capture.ts | 21 +- .../use-terminal-pane-global-effects.ts | 21 +- .../use-terminal-pane-lifecycle.ts | 25 +- src/renderer/src/constants/terminal.ts | 7 +- .../src/hooks/useAutoAckViewedAgent.test.ts | 22 +- .../src/hooks/useAutoAckViewedAgent.ts | 57 +- .../src/hooks/useAutomationDispatchEvents.ts | 12 +- src/renderer/src/hooks/useIpcEvents.test.ts | 126 ++- src/renderer/src/hooks/useIpcEvents.ts | 83 +- .../src/lib/activate-tab-and-focus-pane.ts | 15 +- .../src/lib/focus-terminal-tab-surface.ts | 19 +- .../launch-agent-background-session.test.ts | 52 +- .../lib/launch-agent-background-session.ts | 12 +- .../lib/migration-unsupported-agent-entry.ts | 27 + .../lib/pane-manager/mint-stable-pane-id.ts | 25 + .../lib/pane-manager/mobile-fit-overrides.ts | 22 +- .../pane-fit-resize-observer.test.ts | 3 + .../pane-manager/pane-identity-registry.ts | 79 ++ .../pane-manager/pane-key-resolution.test.ts | 77 ++ .../lib/pane-manager/pane-key-resolution.ts | 70 ++ .../lib/pane-manager/pane-lifecycle.test.ts | 6 + .../src/lib/pane-manager/pane-lifecycle.ts | 5 + .../lib/pane-manager/pane-manager-types.ts | 13 +- .../src/lib/pane-manager/pane-manager.ts | 180 ++--- .../src/lib/pane-manager/pane-public-view.ts | 2 + .../src/lib/pane-manager/pane-split-close.ts | 154 ++++ .../pane-manager/pane-split-scroll.test.ts | 5 + .../pane-terminal-gpu-acceleration.test.ts | 3 + .../lib/pane-manager/pane-tree-ops.test.ts | 3 + .../src/runtime/sync-runtime-graph.ts | 48 +- src/renderer/src/store/slices/agent-status.ts | 138 +++- .../src/store/slices/store-cascades.test.ts | 7 +- .../slices/store-session-cascades.test.ts | 14 +- .../src/store/slices/terminal-helpers.ts | 14 + src/renderer/src/store/slices/terminals.ts | 21 +- src/renderer/src/web/web-preload-api.ts | 3 + src/shared/agent-hook-listener.test.ts | 14 +- src/shared/agent-hook-listener.ts | 8 +- src/shared/agent-status-types.ts | 14 +- src/shared/constants.ts | 1 + src/shared/pane-key.ts | 8 +- src/shared/stable-pane-id.test.ts | 42 + src/shared/stable-pane-id.ts | 45 ++ src/shared/types.ts | 4 +- src/shared/workspace-session-schema.ts | 10 +- .../e2e/activity-agent-pane-isolation.spec.ts | 382 +++++++++ tests/e2e/helpers/terminal.ts | 127 +++ tests/e2e/ssh-localhost.spec.ts | 5 +- tests/e2e/terminal-panes.spec.ts | 193 +++++ tests/e2e/worktree-smart-sort.spec.ts | 70 +- 106 files changed, 5229 insertions(+), 1059 deletions(-) create mode 100644 src/main/agent-hooks/migration-unsupported-pty-state.ts create mode 100644 src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts create mode 100644 src/renderer/src/components/terminal-pane/stale-agent-row.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts create mode 100644 src/renderer/src/lib/migration-unsupported-agent-entry.ts create mode 100644 src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-identity-registry.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-key-resolution.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-split-close.ts create mode 100644 src/shared/stable-pane-id.test.ts create mode 100644 src/shared/stable-pane-id.ts create mode 100644 tests/e2e/activity-agent-pane-isolation.spec.ts diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts new file mode 100644 index 000000000..4e214ee13 --- /dev/null +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -0,0 +1,47 @@ +import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' + +type MigrationUnsupportedPtyEvent = + | { type: 'set'; entry: MigrationUnsupportedPtyEntry } + | { type: 'clear'; ptyId: string } + +const entriesByPtyId = new Map() +let listener: ((event: MigrationUnsupportedPtyEvent) => void) | null = null +let persistenceListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null = null + +export function setMigrationUnsupportedPtyListener( + nextListener: ((event: MigrationUnsupportedPtyEvent) => void) | null +): void { + listener = nextListener +} + +export function getMigrationUnsupportedPtySnapshot(): MigrationUnsupportedPtyEntry[] { + return [...entriesByPtyId.values()] +} + +export function setMigrationUnsupportedPtyPersistenceListener( + nextListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null +): void { + persistenceListener = nextListener +} + +export function setMigrationUnsupportedPty(entry: MigrationUnsupportedPtyEntry): void { + entriesByPtyId.set(entry.ptyId, entry) + listener?.({ type: 'set', entry }) + persistenceListener?.(getMigrationUnsupportedPtySnapshot()) +} + +export function clearMigrationUnsupportedPty(ptyId: string): void { + if (!entriesByPtyId.delete(ptyId)) { + return + } + listener?.({ type: 'clear', ptyId }) + persistenceListener?.(getMigrationUnsupportedPtySnapshot()) +} + +export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void { + for (const [ptyId, entry] of entriesByPtyId) { + if (entry.paneKey === paneKey) { + clearMigrationUnsupportedPty(ptyId) + } + } +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 6033bf302..dbeaa3e55 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -18,6 +18,7 @@ import { AGENT_STATUS_MAX_FIELD_LENGTH, parseAgentStatusPayload } from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() @@ -27,7 +28,16 @@ vi.mock('../telemetry/client', () => ({ track: trackMock })) -const PANE = 'tab-1:0' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' +const LEAF_4 = '44444444-4444-4444-8444-444444444444' +const LEAF_5 = '55555555-5555-4555-8555-555555555555' +const PANE = makePaneKey('tab-1', LEAF_1) +const GOOD_PANE = makePaneKey('tab-good', LEAF_2) +const OLD_PANE = makePaneKey('tab-old', LEAF_3) +const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4) +const TAB_A_PANE = makePaneKey('tab-A', LEAF_5) type Body = { paneKey: string @@ -358,14 +368,15 @@ describe('AgentHookServer listener replay', () => { ) const warnsAfterFirst = warn.mock.calls.length + const secondPane = makePaneKey('tab-2', LEAF_2) server.ingestRemote( { - paneKey: 'tab-2:0', + paneKey: secondPane, env: 'development', version: '999', payload: { state: 'working', - paneKey: 'tab-2:0', + paneKey: secondPane, updatedAt: Date.now(), agentType: 'claude' } @@ -391,9 +402,10 @@ describe('AgentHookServer listener replay', () => { server.setListener(listener) const oversizedPrompt = 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH + 50) + const remotePane = makePaneKey('tab-3', LEAF_3) server.ingestRemote( { - paneKey: ' tab-3:0 ', + paneKey: ` ${remotePane} `, tabId: ' tab-3 ', worktreeId: ' wt-3 ', env: 'remote', @@ -410,7 +422,7 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - paneKey: 'tab-3:0', + paneKey: remotePane, tabId: 'tab-3', worktreeId: 'wt-3', connectionId: 'conn-9', @@ -1762,9 +1774,10 @@ describe('Endpoint file lifecycle', () => { }) }) try { + const remotePane = makePaneKey('tab-3', LEAF_3) server.ingestRemote( { - paneKey: 'tab-3:0', + paneKey: remotePane, tabId: 'tab-3', worktreeId: 'wt-3', payload: { @@ -1776,7 +1789,7 @@ describe('Endpoint file lifecycle', () => { 'conn-42' ) expect(events).toHaveLength(1) - expect(events[0].paneKey).toBe('tab-3:0') + expect(events[0].paneKey).toBe(remotePane) expect(events[0].connectionId).toBe('conn-42') expect(events[0].payload).toMatchObject({ state: 'working', @@ -2036,14 +2049,14 @@ describe('Last-status persistence', () => { }, // Embedded paneKey mismatch — drop. [PANE]: { - paneKey: 'tab-x:99', + paneKey: makePaneKey('tab-x', LEAF_2), receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } }, // Valid. - 'tab-good:0': { - paneKey: 'tab-good:0', + [GOOD_PANE]: { + paneKey: GOOD_PANE, tabId: 'tab-good', receivedAt: recentTs(), stateStartedAt: recentTs(-1000), @@ -2064,7 +2077,7 @@ describe('Last-status persistence', () => { expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - paneKey: 'tab-good:0', + paneKey: GOOD_PANE, payload: expect.objectContaining({ prompt: 'survived' }) }) ) @@ -2082,16 +2095,16 @@ describe('Last-status persistence', () => { version: 2, entries: { // Stale — should be dropped. - 'tab-old:0': { - paneKey: 'tab-old:0', + [OLD_PANE]: { + paneKey: OLD_PANE, tabId: 'tab-old', receivedAt: eightDaysAgoMs, stateStartedAt: eightDaysAgoMs - 1000, payload: { state: 'done', prompt: 'old', agentType: 'claude' } }, // Recent — should survive. - 'tab-fresh:0': { - paneKey: 'tab-fresh:0', + [FRESH_PANE]: { + paneKey: FRESH_PANE, tabId: 'tab-fresh', receivedAt: recentTs(), stateStartedAt: recentTs(-1000), @@ -2108,7 +2121,7 @@ describe('Last-status persistence', () => { }) try { const snapshot = server.getStatusSnapshot() - expect(snapshot.map((e) => e.paneKey)).toEqual(['tab-fresh:0']) + expect(snapshot.map((e) => e.paneKey)).toEqual([FRESH_PANE]) } finally { server.stop() } @@ -2121,8 +2134,8 @@ describe('Last-status persistence', () => { JSON.stringify({ version: 2, entries: { - 'tab-A:0': { - paneKey: 'tab-A:0', + [TAB_A_PANE]: { + paneKey: TAB_A_PANE, // Why: deliberately divergent — paneKey says tab-A, the entry // claims tab-B. Sanitizer must drop rather than hydrate this // inconsistent row. @@ -2188,7 +2201,7 @@ describe('Last-status persistence', () => { // Why: a no-op clearPaneState on a paneKey not in the cache is a // mutation site that should NOT trigger a redundant write. (clear was // designed to bail when nothing was evicted.) - server.clearPaneState('non-existent:0') + server.clearPaneState(makePaneKey('non-existent', LEAF_5)) server.flushStatusPersistSync() // Touch back to the same mtime would let the test pass spuriously, so // assert no rewrite happened by checking that mtime is unchanged after @@ -2287,6 +2300,42 @@ describe('AgentHookServer ingestRemote', () => { expect(listener).not.toHaveBeenCalled() }) + it('drops remote relay envelopes with legacy numeric paneKeys before cache mutation', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-other', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) + it('rejects empty connectionId', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 9c3f73682..072874998 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -39,6 +39,7 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { parsePaneKey } from '../../shared/stable-pane-id' export type { AgentHookSource } @@ -95,26 +96,18 @@ type LastStatusFile = { entries: Record } -// Why: paneKey is `${tabId}:${paneId}` — exactly one ':' with non-empty -// segments on either side. Used both at write time (defensive) and at -// hydrate time (drop on mismatch). +// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix +// at write/hydrate time so legacy numeric rows fail closed. export function isValidPaneKey(value: unknown): value is string { - if (typeof value !== 'string' || value.length === 0) { - return false - } - const colon = value.indexOf(':') - if (colon <= 0 || colon === value.length - 1) { - return false - } - // Why: exactly one colon. Anything weirder is corruption. - return !value.includes(':', colon + 1) + return typeof value === 'string' && parsePaneKey(value) !== null } function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown ): EnrichedAgentHookEventPayload | null { - if (!isValidPaneKey(paneKey)) { + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey) { return null } if (typeof rawEntry !== 'object' || rawEntry === null) { @@ -128,10 +121,10 @@ function sanitizeHydratedEntry( if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { return null } - // Why: paneKey is `${tabId}:${paneId}`; a stored entry whose tabId field + // Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field // diverges from the key's tab segment is corruption (renamer bug, manual // edit, future shape drift). Drop instead of hydrating an inconsistent row. - if (typeof tabId === 'string' && tabId !== paneKey.slice(0, paneKey.indexOf(':'))) { + if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { return null } const worktreeId = record.worktreeId @@ -349,6 +342,7 @@ export class AgentHookServer { // length-caps paneKey before caching, so the cache key here must follow // the same rule or remote-vs-local events for the same pane would diverge. const paneKey = envelope.paneKey.trim() + const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) return @@ -356,6 +350,9 @@ export class AgentHookServer { if (paneKey.length > MAX_PANE_KEY_LEN) { return } + if (!parsedPaneKey) { + return + } if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { return } @@ -369,6 +366,9 @@ export class AgentHookServer { envelope.tabId !== undefined && envelope.tabId.trim().length > 0 ? envelope.tabId.trim() : undefined + if (tabId !== undefined && tabId !== parsedPaneKey.tabId) { + return + } const worktreeId = envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 ? envelope.worktreeId.trim() diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0570519c1..96ac6932a 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -142,7 +142,12 @@ export class DaemonPtyAdapter implements IPtyProvider { // but should still return the cached cold restore data. const cachedRestore = this.coldRestoreCache.get(sessionId) if (cachedRestore) { - return { id: sessionId, pid, coldRestore: cachedRestore } + return { + id: sessionId, + pid, + coldRestore: cachedRestore, + ...(!result.isNew ? { isReattach: true } : {}) + } } this.activeSessionIds.add(sessionId) @@ -197,7 +202,7 @@ export class DaemonPtyAdapter implements IPtyProvider { const isReattach = !result.isNew if (!isReattach || !result.snapshot) { - return { id: sessionId, pid } + return { id: sessionId, pid, ...(isReattach ? { isReattach: true } : {}) } } const isAltScreen = result.snapshot.modes.alternateScreen diff --git a/src/main/index.ts b/src/main/index.ts index d034bac1f..e87525574 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,6 +49,7 @@ import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' +import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' import { claudeHookService } from './claude/hook-service' import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' @@ -309,6 +310,7 @@ function openMainWindow(): BrowserWindow { // replay-loop through lastStatusByPaneKey runs only on deliberate // window recreations instead of stacking on top of stale listeners. agentHookServer.setListener(null) + setMigrationUnsupportedPtyListener(null) // Why: any running synthesized-title spinner intervals would fire into a // destroyed webContents; stop them all here instead of deferring to // per-pane teardown, which may never run for restored-but-never-torn-down @@ -347,6 +349,18 @@ function openMainWindow(): BrowserWindow { } } ) + setMigrationUnsupportedPtyListener((event) => { + if (mainWindow?.isDestroyed()) { + return + } + if (event.type === 'set') { + mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) + } else { + mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { + ptyId: event.ptyId + }) + } + }) return window } diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index a87ea123a..f82ade795 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentHookServerModule from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' // Why: cover the agentStatus:drop IPC handler — it must propagate the // renderer dismissal to dropStatusEntry so the on-disk last-status file @@ -12,6 +13,7 @@ const onHandlers = new Map void> const handleHandlers = new Map unknown>() const removeHandler = vi.fn() const removeAllListeners = vi.fn() +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') vi.mock('electron', () => ({ ipcMain: { @@ -69,7 +71,7 @@ describe('agentStatus:getSnapshot IPC', () => { it('returns the hook cache snapshot', async () => { const snapshot = [ { - paneKey: 'tab-1:0', + paneKey: PANE_KEY, state: 'done', prompt: 'p', agentType: 'claude', @@ -94,8 +96,8 @@ describe('agentStatus:drop IPC', () => { const handler = onHandlers.get('agentStatus:drop') expect(handler).toBeDefined() - handler!({}, 'tab-1:0') - expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') + handler!({}, PANE_KEY) + expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY) }) it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { @@ -110,9 +112,10 @@ describe('agentStatus:drop IPC', () => { null, {}, [], + 'tab-1:0', // legacy numeric pane-key suffix 'no-colon', // missing colon — rejected by isValidPaneKey ':leading', // empty tabId half - 'trailing:', // empty paneId half + 'trailing:', // empty leafId half 'a:b:c' // multiple colons ] for (const value of bad) { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 2cf3929b3..350c1c920 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,7 +1,14 @@ import { ipcMain } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../shared/agent-status-types' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { + clearMigrationUnsupportedPtysForPaneKey, + getMigrationUnsupportedPtySnapshot +} from '../agent-hooks/migration-unsupported-pty-state' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -25,6 +32,7 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:cursorStatus') ipcMain.removeHandler('agentHooks:droidStatus') ipcMain.removeHandler('agentStatus:getSnapshot') + ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't // round-trip a response. Removing first keeps re-registration safe even @@ -40,6 +48,7 @@ export function registerAgentHookHandlers(): void { // wipe the per-pane prompt/tool caches, which the next hook event for that // (still-alive) pane needs to render a coherent row. agentHookServer.dropStatusEntry(paneKey) + clearMigrationUnsupportedPtysForPaneKey(paneKey) } catch (err) { console.warn('[agent-hooks] dropStatusEntry failed:', err) } @@ -49,6 +58,10 @@ export function registerAgentHookHandlers(): void { // lose replayed statuses while its local store is still empty. return agentHookServer.getStatusSnapshot() }) + ipcMain.handle( + 'agentStatus:getMigrationUnsupportedSnapshot', + (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() + ) // Why: errors from getStatus() (fs permission denied, homedir resolution // failure, etc.) must be reported inline via state:'error' so the sidebar can diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 38ca92c39..2cd81d45d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -25,7 +25,12 @@ const { piClearPtyMock, isPwshAvailableMock, trackMock, - classifyErrorMock + classifyErrorMock, + registerPtyMock, + unregisterPtyMock, + setMigrationUnsupportedPtyMock, + clearMigrationUnsupportedPtyMock, + clearMigrationUnsupportedPtysForPaneKeyMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -48,7 +53,12 @@ const { piBuildPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), - classifyErrorMock: vi.fn() + classifyErrorMock: vi.fn(), + registerPtyMock: vi.fn(), + unregisterPtyMock: vi.fn(), + setMigrationUnsupportedPtyMock: vi.fn(), + clearMigrationUnsupportedPtyMock: vi.fn(), + clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn() })) vi.mock('electron', () => ({ @@ -113,11 +123,25 @@ vi.mock('../telemetry/client', () => ({ vi.mock('../telemetry/classify-error', () => ({ classifyError: classifyErrorMock })) + +vi.mock('../memory/pty-registry', () => ({ + registerPty: registerPtyMock, + unregisterPty: unregisterPtyMock +})) + +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + setMigrationUnsupportedPty: setMigrationUnsupportedPtyMock, + clearMigrationUnsupportedPty: clearMigrationUnsupportedPtyMock, + clearMigrationUnsupportedPtysForPaneKey: clearMigrationUnsupportedPtysForPaneKeyMock +})) import { LocalPtyProvider } from '../providers/local-pty-provider' +import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, registerSshPtyProvider, + clearProviderPtyState, deletePtyOwnership, + getPtyIdForPaneKey, setPtyOwnership, setLocalPtyProvider, unregisterSshPtyProvider @@ -187,6 +211,11 @@ describe('registerPtyHandlers', () => { isPwshAvailableMock.mockReset() trackMock.mockReset() classifyErrorMock.mockReset() + registerPtyMock.mockReset() + unregisterPtyMock.mockReset() + setMigrationUnsupportedPtyMock.mockReset() + clearMigrationUnsupportedPtyMock.mockReset() + clearMigrationUnsupportedPtysForPaneKeyMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -960,14 +989,15 @@ describe('registerPtyHandlers', () => { undefined, store as never ) + const leafId = '11111111-1111-4111-8111-111111111111' await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - env: { FOO: 'bar' }, + env: { FOO: 'bar', ORCA_PANE_KEY: makePaneKey('tab-1', leafId) }, connectionId: 'ssh-1', worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1' + leafId }) const env = sshSpawn.mock.calls.at(-1)![0].env // Why: every host-local var must be absent over SSH — the hook @@ -994,16 +1024,32 @@ describe('registerPtyHandlers', () => { ptyId: 'ssh-pty', worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1', + leafId, state: 'attached' }) ) expect(store.persistPtyBinding).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1', + leafId, ptyId: 'ssh-pty' }) + + store.upsertSshRemotePtyLease.mockClear() + store.persistPtyBinding.mockClear() + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-1:pane:1' }, + connectionId: 'ssh-1', + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + expect(store.upsertSshRemotePtyLease).toHaveBeenCalledTimes(1) + expect(sshSpawn.mock.calls.at(-1)?.[0].env.ORCA_PANE_KEY).toBeUndefined() + expect(store.upsertSshRemotePtyLease.mock.calls[0]?.[0]).not.toHaveProperty('leafId') + expect(store.persistPtyBinding).not.toHaveBeenCalled() }) it('marks a caller-supplied SSH session expired when remote reattach is gone', async () => { @@ -1351,19 +1397,23 @@ describe('registerPtyHandlers', () => { const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' try { + const leafId = '22222222-2222-4222-8222-222222222222' + const paneKey = makePaneKey('tab-2', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: { FOO: 'bar', - ORCA_PANE_KEY: 'tab-2:0', + ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-2', ORCA_WORKTREE_ID: 'wt-2' }, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + tabId: 'tab-2', + leafId }) const env = sshSpawn.mock.calls.at(-1)![0].env - expect(env.ORCA_PANE_KEY).toBe('tab-2:0') + expect(env.ORCA_PANE_KEY).toBe(paneKey) expect(env.ORCA_TAB_ID).toBe('tab-2') expect(env.ORCA_WORKTREE_ID).toBe('wt-2') // Local hook server coords still must NOT cross the wire — the @@ -1641,7 +1691,7 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(mainWindow as never, runtime as never) - const paneKey = 'tab-cli:1' + const paneKey = makePaneKey('tab-cli', '11111111-1111-4111-8111-111111111111') const gen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { paneKey })) as number @@ -2276,6 +2326,102 @@ describe('registerPtyHandlers', () => { } }) + it('registers only validated stable pane keys in the local PTY memory registry', async () => { + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: 'tab-1:0' } + }) + + expect(registerPtyMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: null + }) + ) + expect(setMigrationUnsupportedPtyMock).toHaveBeenCalledWith( + expect.objectContaining({ + ptyId: expect.any(String), + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + paneKey: makePaneKey('tab-1', leafId), + reason: 'legacy-numeric-pane-key', + source: 'local' + }) + ) + + const stablePaneKey = makePaneKey('tab-1', leafId) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + }) + + expect(registerPtyMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: stablePaneKey + }) + ) + expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: makePaneKey('tab-2', leafId) } + }) + + expect(registerPtyMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: null + }) + ) + }) + + it('does not let an old PTY teardown clear a newer pane-key owner', async () => { + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) + + const first = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + })) as { id: string } + const second = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + })) as { id: string } + + expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id) + clearAgentHookPaneStateMock.mockClear() + clearProviderPtyState(first.id) + + expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id) + expect(clearAgentHookPaneStateMock).not.toHaveBeenCalledWith(stablePaneKey) + + clearProviderPtyState(second.id) + expect(getPtyIdForPaneKey(stablePaneKey)).toBeUndefined() + expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey) + }) + it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2b304baea..a0d4cc4c2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -38,6 +38,12 @@ import { } from '../../shared/telemetry-events' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + clearMigrationUnsupportedPty, + clearMigrationUnsupportedPtysForPaneKey, + setMigrationUnsupportedPty +} from '../agent-hooks/migration-unsupported-pty-state' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -102,8 +108,37 @@ const ptyPendingGenByPtyId = new Map() // and cleared on PTY teardown. const rendererSerializerByPtyId = new Set() +function parseValidPaneKey(paneKey: unknown): ReturnType { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + return parsePaneKey(paneKey) +} + function isValidPaneKey(paneKey: unknown): paneKey is string { - return typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 + return parseValidPaneKey(paneKey) !== null +} + +function parseLegacyNumericPaneKey( + paneKey: unknown +): { tabId: string; numericPaneId: string } | null { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + const trimmed = paneKey.trim() + const delimiter = trimmed.indexOf(':') + if ( + delimiter <= 0 || + delimiter !== trimmed.lastIndexOf(':') || + delimiter === trimmed.length - 1 + ) { + return null + } + const numericPaneId = trimmed.slice(delimiter + 1) + if (!/^\d+$/.test(numericPaneId)) { + return null + } + return { tabId: trimmed.slice(0, delimiter), numericPaneId } } function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { @@ -431,6 +466,7 @@ export function clearProviderPtyState(id: string): void { // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) + clearMigrationUnsupportedPty(id) rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. @@ -438,9 +474,12 @@ export function clearProviderPtyState(id: string): void { // correlate a ptyId back to its paneKey. const paneKey = ptyPaneKey.get(id) if (paneKey) { - agentHookServer.clearPaneState(paneKey) + const stillOwnsPaneKey = paneKeyPtyId.get(paneKey) === id + if (stillOwnsPaneKey) { + agentHookServer.clearPaneState(paneKey) + paneKeyPtyId.delete(paneKey) + } ptyPaneKey.delete(id) - paneKeyPtyId.delete(paneKey) // Why: drop the pre-signal pending entry only if it still belongs to THIS // PTY's spawn generation. If a remount for the same paneKey has already // pre-signaled a new gen, this teardown must NOT touch it — otherwise @@ -452,14 +491,16 @@ export function clearProviderPtyState(id: string): void { settlePendingPaneSerializer(paneKey, ownedGen) } ptyPendingGenByPtyId.delete(id) - // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId - // entries so a listener that re-reads the map sees the post-teardown - // state. Wrap each call so one throwing listener cannot block the rest. - for (const listener of paneKeyTeardownListeners) { - try { - listener(paneKey) - } catch (err) { - console.error('[pty] paneKey teardown listener threw', err) + if (stillOwnsPaneKey) { + // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId + // entries so a listener that re-reads the map sees the post-teardown + // state. Wrap each call so one throwing listener cannot block the rest. + for (const listener of paneKeyTeardownListeners) { + try { + listener(paneKey) + } catch (err) { + console.error('[pty] paneKey teardown listener threw', err) + } } } } @@ -1067,14 +1108,9 @@ export function registerPtyHandlers( const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn const effectiveSessionId = args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - // Why: the renderer unconditionally sets ORCA_PANE_KEY/TAB_ID/WORKTREE_ID - // on every spawn, including SSH ones (see pty-connection.ts). When the - // remote-agent-hook feature is OFF, the relay-side hook server is not - // wired up and forwarding these vars across the SSH wire would let a - // future relay build start posting hook events Orca cannot route. Strip - // them on the SSH path while the flag is off; flag ON keeps them so the - // relay's pty-handler sees the paneKey on spawn env. See - // docs/design/agent-status-over-ssh.md §8 (commit #6 gate location a). + // Why: the renderer sets pane env for SSH too. Only forward it to the + // remote when the relay hook path is enabled; otherwise a newer relay + // could emit statuses this Orca build is not prepared to route. let sshSourceEnv = args.env if (args.connectionId && !isRemoteAgentHooksEnabled()) { if ( @@ -1090,7 +1126,41 @@ export function registerPtyHandlers( sshSourceEnv = stripped } } - const baseEnv = claudeAuth ? { ...sshSourceEnv, ...claudeAuth.envPatch } : sshSourceEnv + const baseEnvWithAuth = claudeAuth + ? { ...sshSourceEnv, ...claudeAuth.envPatch } + : sshSourceEnv + const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY + const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) + const verifiedPaneKey = + parsedSpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === parsedSpawnPaneKey.tabId && + args.leafId === parsedSpawnPaneKey.leafId + ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) + : null + const verifiedLeafId = + verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null + const metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) + const migrationUnsupportedPaneKey = + legacySpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === legacySpawnPaneKey.tabId && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null + const baseEnv = baseEnvWithAuth + ? { ...baseEnvWithAuth, ...(verifiedPaneKey ? { ORCA_PANE_KEY: verifiedPaneKey } : {}) } + : undefined + if (baseEnv && !verifiedPaneKey) { + // Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the + // key proven to match this spawn's tab+leaf may leave the IPC boundary. + delete baseEnv.ORCA_PANE_KEY + } + const validatedPaneKey = verifiedPaneKey + const validatedLeafId = verifiedLeafId ?? metadataLeafId let env: Record | undefined = baseEnv const preAllocatedHandle = runtime && !(provider instanceof LocalPtyProvider) @@ -1262,7 +1332,7 @@ export function registerPtyHandlers( ptyId: result.id, ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' ? { leafId: args.leafId } : {}), + ...(validatedLeafId ? { leafId: validatedLeafId } : {}), state: 'attached', lastAttachedAt: Date.now() }) @@ -1280,16 +1350,35 @@ export function registerPtyHandlers( if ( (isDaemonHostSpawn || args.connectionId) && store && - args.worktreeId !== undefined && - args.tabId !== undefined && - args.leafId !== undefined + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + validatedLeafId !== null ) { - store.persistPtyBinding({ - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - ptyId: result.id - }) + try { + store.persistPtyBinding({ + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: validatedLeafId, + ptyId: result.id + }) + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + if (!result.isReattach) { + try { + await provider.shutdown(result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(result.id) + deletePtyOwnership(result.id) + } + if (!result.isReattach && args.connectionId && store) { + store.removeSshRemotePtyLease(args.connectionId, result.id) + } + throw new Error( + 'Failed to save terminal session state. Check disk space and Orca data directory permissions, then try again.' + ) + } } // Why: pre-signal cooperation gate — when the renderer has declared it // will own the serializer for this paneKey, suppress the daemon-snapshot @@ -1297,16 +1386,13 @@ export function registerPtyHandlers( // is the sole authority. The pre-signal is keyed on paneKey because at // spawn time the renderer doesn't yet know the new ptyId. See // docs/mobile-prefer-renderer-scrollback.md. - const spawnPaneKey = args.env?.ORCA_PANE_KEY - const rendererPreSignaled = isValidPaneKey(spawnPaneKey) - ? pendingByPaneKey.has(spawnPaneKey) - : false + const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) // Why: capture the pending gen at spawn time so teardown for THIS PTY // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. - if (isValidPaneKey(spawnPaneKey) && rendererPreSignaled) { - const gen = pendingByPaneKey.get(spawnPaneKey) + if (validatedPaneKey && rendererPreSignaled) { + const gen = pendingByPaneKey.get(validatedPaneKey) if (gen !== undefined) { ptyPendingGenByPtyId.set(result.id, gen) } @@ -1356,8 +1442,28 @@ export function registerPtyHandlers( // Record type is not actually enforced at the boundary. // Narrow to a bounded string so malformed or oversized values cannot // pollute ptyPaneKey or the downstream clearPaneState call. - const paneKey = args.env?.ORCA_PANE_KEY - const rememberedPaneKey = rememberPaneKeyForPty(result.id, paneKey) + const rememberedPaneKey = validatedPaneKey + ? rememberPaneKeyForPty(result.id, validatedPaneKey) + : null + if (validatedPaneKey) { + if (!result.isReattach) { + clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) + } + } else if (migrationUnsupportedPaneKey && legacySpawnPaneKey) { + // Why: old live PTYs can still carry `${tabId}:${numericPaneId}` in + // ORCA_PANE_KEY. Only surface a migration row when this spawn also + // proves the owning UUID leaf through the renderer IPC metadata. + setMigrationUnsupportedPty({ + ptyId: result.id, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + tabId: legacySpawnPaneKey.tabId, + leafId: args.leafId, + paneKey: migrationUnsupportedPaneKey, + reason: 'legacy-numeric-pane-key', + source: args.connectionId ? 'ssh' : 'local', + updatedAt: Date.now() + }) + } // Why: register local PTYs (connectionId falsy) with the memory // collector so it can walk each PTY's process subtree and attribute // memory back to its worktree. SSH PTYs execute remotely and their diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 894446991..ec9aceee2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -6,10 +6,15 @@ import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync import { join } from 'path' import { tmpdir } from 'os' import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types' +import { isTerminalLeafId } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } +const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111' +const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222' +const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333' +const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444' vi.mock('electron', () => ({ app: { @@ -97,18 +102,18 @@ function makeSessionWithTerminalBuffers(): WorkspaceSessionState { }, terminalLayoutsByTabId: { 'local-tab': { - root: { type: 'leaf', leafId: 'leaf-local' }, - activeLeafId: 'leaf-local', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - buffersByLeafId: { 'leaf-local': 'local-scrollback' }, - ptyIdsByLeafId: { 'leaf-local': 'local-pty' } + buffersByLeafId: { [TEST_LEAF_1]: 'local-scrollback' }, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } }, 'remote-tab': { - root: { type: 'leaf', leafId: 'leaf-remote' }, - activeLeafId: 'leaf-remote', + root: { type: 'leaf', leafId: TEST_LEAF_2 }, + activeLeafId: TEST_LEAF_2, expandedLeafId: null, - buffersByLeafId: { 'leaf-remote': 'remote-scrollback' }, - ptyIdsByLeafId: { 'leaf-remote': 'remote-pty' } + buffersByLeafId: { [TEST_LEAF_2]: 'remote-scrollback' }, + ptyIdsByLeafId: { [TEST_LEAF_2]: 'remote-pty' } } } } @@ -197,6 +202,24 @@ describe('Store', () => { expect(repos[0].gitUsername).toBe('testuser') }) + it('drops malformed migration-unsupported PTY entries on load', async () => { + const repo = makeRepo() + writeDataFile({ + schemaVersion: 1, + repos: [repo], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + migrationUnsupportedPtyEntries: {} + }) + + const store = await createStore() + + expect(store.getRepos()).toHaveLength(1) + }) + it('can clear an automation back to the project default branch', async () => { const store = await createStore() store.addRepo(makeRepo({ worktreeBaseRef: 'origin/main' })) @@ -1048,10 +1071,10 @@ describe('Store', () => { const session = store.getWorkspaceSession() expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['local-tab'].ptyIdsByLeafId).toEqual({ - 'leaf-local': 'local-pty' + [TEST_LEAF_1]: 'local-pty' }) expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({ - 'leaf-remote': 'remote-scrollback' + [TEST_LEAF_2]: 'remote-scrollback' }) }) @@ -1087,10 +1110,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { 'remote-tab': { - root: { type: 'leaf', leafId: 'leaf-remote' }, - activeLeafId: 'leaf-remote', + root: { type: 'leaf', leafId: TEST_LEAF_2 }, + activeLeafId: TEST_LEAF_2, expandedLeafId: null, - buffersByLeafId: { 'leaf-remote': 'maybe-remote-scrollback' } + buffersByLeafId: { [TEST_LEAF_2]: 'maybe-remote-scrollback' } } } }) @@ -1098,7 +1121,7 @@ describe('Store', () => { expect( store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].buffersByLeafId ).toEqual({ - 'leaf-remote': 'maybe-remote-scrollback' + [TEST_LEAF_2]: 'maybe-remote-scrollback' }) }) @@ -1120,7 +1143,7 @@ describe('Store', () => { const session = store.getWorkspaceSession() expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({ - 'leaf-remote': 'remote-scrollback' + [TEST_LEAF_2]: 'remote-scrollback' }) }) @@ -1141,16 +1164,121 @@ describe('Store', () => { expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199') }) - it('does not restore cleared SSH bindings after a lease expired', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: 'leaf1', - state: 'expired' + it('remaps legacy SSH lease leaf ids when loading legacy workspace layouts', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'remote-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote-pty' } + } + } + }, + sshRemotePtyLeases: [ + { + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: 'pane:1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] }) + + const store = await createStore() + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null + if (leafId === null) { + throw new Error('Expected remapped leaf id') + } + expect(isTerminalLeafId(leafId)).toBe(true) + expect(layout.ptyIdsByLeafId).toEqual({ [leafId]: 'remote-pty' }) + expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) + }) + + it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'remote-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } + } + } + }, + sshRemotePtyLeases: [ + { + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: 'pane:1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(TEST_LEAF_1) + }) + + it('normalizes stale legacy session writes to prior UUID leaves before preserving bindings', async () => { + const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', @@ -1171,10 +1299,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) @@ -1199,8 +1327,402 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + + const session = store.getWorkspaceSession() + const layout = session.terminalLayoutsByTabId.tab1 + expect(layout.root).toEqual({ type: 'leaf', leafId: TEST_LEAF_1 }) + expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty' }) + expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') + }) + + it('promotes an empty tab layout to a durable UUID root when persisting the first PTY binding', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + }) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'daemon-pty' + }) + + const session = store.getWorkspaceSession() + expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') + expect(session.terminalLayoutsByTabId.tab1).toEqual({ + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' } + }) + }) + + it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + } + }) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-2' + }) + + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + expect(layout.root).toEqual({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }) + expect(layout.activeLeafId).toBe(TEST_LEAF_2) + expect(layout.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'pty-1', + [TEST_LEAF_2]: 'pty-2' + }) + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'pty-1', + [TEST_LEAF_2]: 'pty-2' + }) + }) + + it('preserves a sync-persisted UUID root when a stale empty layout write arrives', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + }) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'daemon-pty' + }) + + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + + const session = store.getWorkspaceSession() + expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') + expect(session.terminalLayoutsByTabId.tab1).toEqual({ + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' } + }) + }) + + it('drops legacy leaf-keyed records from mixed-version writes before binding preservation', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'daemon-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' }, + buffersByLeafId: { [TEST_LEAF_1]: 'Current buffer' }, + titlesByLeafId: { [TEST_LEAF_1]: 'Current' } + } + } + }) + + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: 'pane:1', + expandedLeafId: 'pane:1', + ptyIdsByLeafId: { 'pane:1': 'stale-pty' }, + buffersByLeafId: { 'pane:1': 'Stale buffer' }, + titlesByLeafId: { 'pane:1': 'Stale' } + } + } + }) + + const session = store.getWorkspaceSession() + const layout = session.terminalLayoutsByTabId.tab1 + expect(layout.activeLeafId).toBe(TEST_LEAF_1) + expect(layout.expandedLeafId).toBeNull() + expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' }) + expect(layout.buffersByLeafId).toEqual({ [TEST_LEAF_1]: 'Current buffer' }) + expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' }) + expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') + }) + + it('does not reuse prior UUID leaves by position when legacy leaf counts changed', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, + ratio: 0.5 + }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null + } + } + }) + + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null + } + } + }) + + const root = store.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + const leafId = root?.type === 'leaf' ? root.leafId : null + if (leafId === null) { + throw new Error('Expected normalized leaf') + } + expect(isTerminalLeafId(leafId)).toBe(true) + expect(leafId).not.toBe(TEST_LEAF_1) + expect(leafId).not.toBe(TEST_LEAF_2) + }) + + it('does not restore cleared SSH bindings after a lease expired', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + state: 'expired' + }) + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'remote-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } + } + } + }) + + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } @@ -1219,7 +1741,7 @@ describe('Store', () => { ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab-expired', - leafId: 'leaf-expired', + leafId: TEST_LEAF_EXPIRED, state: 'expired' }) store.setWorkspaceSession({ @@ -1242,10 +1764,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { 'tab-live': { - root: { type: 'leaf', leafId: 'leaf-live' }, - activeLeafId: 'leaf-live', + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-live': 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } } } }) @@ -1270,8 +1792,8 @@ describe('Store', () => { }, terminalLayoutsByTabId: { 'tab-live': { - root: { type: 'leaf', leafId: 'leaf-live' }, - activeLeafId: 'leaf-live', + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: {} } @@ -1281,7 +1803,7 @@ describe('Store', () => { const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - 'leaf-live': 'remote-pty' + [TEST_LEAF_LIVE]: 'remote-pty' }) }) @@ -1293,7 +1815,7 @@ describe('Store', () => { ptyId: 'remote-pty', worktreeId: 'repo-live::/wt', tabId: 'tab-live', - leafId: 'leaf-live', + leafId: TEST_LEAF_LIVE, state: 'expired' }) store.upsertSshRemotePtyLease({ @@ -1301,7 +1823,7 @@ describe('Store', () => { ptyId: 'remote-pty', worktreeId: 'repo-live::/wt', tabId: 'tab-live', - leafId: 'leaf-live', + leafId: TEST_LEAF_LIVE, state: 'detached' }) store.setWorkspaceSession({ @@ -1324,10 +1846,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { 'tab-live': { - root: { type: 'leaf', leafId: 'leaf-live' }, - activeLeafId: 'leaf-live', + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-live': 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } } } }) @@ -1352,8 +1874,8 @@ describe('Store', () => { }, terminalLayoutsByTabId: { 'tab-live': { - root: { type: 'leaf', leafId: 'leaf-live' }, - activeLeafId: 'leaf-live', + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: {} } @@ -1363,7 +1885,7 @@ describe('Store', () => { const session = store.getWorkspaceSession() expect(session.tabsByWorktree['repo-live::/wt'][0].ptyId).toBe('remote-pty') expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - 'leaf-live': 'remote-pty' + [TEST_LEAF_LIVE]: 'remote-pty' }) }) @@ -1394,10 +1916,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) @@ -1422,8 +1944,8 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } @@ -1432,7 +1954,9 @@ describe('Store', () => { const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ leaf1: 'remote-pty' }) + expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'remote-pty' + }) }) it('does not treat layout-level leases missing worktree context as contextual matches', async () => { @@ -1441,7 +1965,7 @@ describe('Store', () => { targetId: 'ssh-1', ptyId: 'remote-pty', tabId: 'tab1', - leafId: 'leaf1', + leafId: TEST_LEAF_1, state: 'expired' }) store.setWorkspaceSession({ @@ -1464,10 +1988,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) @@ -1492,8 +2016,8 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } @@ -1501,7 +2025,7 @@ describe('Store', () => { }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - leaf1: 'remote-pty' + [TEST_LEAF_1]: 'remote-pty' }) }) @@ -1512,7 +2036,7 @@ describe('Store', () => { ptyId: 'remote-pty-1', worktreeId: 'wt1', tabId: 'tab1', - leafId: 'leaf1', + leafId: TEST_LEAF_1, state: 'detached' }) store.upsertSshRemotePtyLease({ @@ -1520,7 +2044,7 @@ describe('Store', () => { ptyId: 'remote-pty-2', worktreeId: 'wt1', tabId: 'tab1', - leafId: 'leaf2', + leafId: TEST_LEAF_2, state: 'detached' }) store.setWorkspaceSession({ @@ -1546,13 +2070,16 @@ describe('Store', () => { root: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf1' }, - second: { type: 'leaf', leafId: 'leaf2' }, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, - activeLeafId: 'leaf2', + activeLeafId: TEST_LEAF_2, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty-1', leaf2: 'remote-pty-2' } + ptyIdsByLeafId: { + [TEST_LEAF_1]: 'remote-pty-1', + [TEST_LEAF_2]: 'remote-pty-2' + } } } }) @@ -1580,20 +2107,20 @@ describe('Store', () => { root: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf1' }, - second: { type: 'leaf', leafId: 'leaf2' }, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, - activeLeafId: 'leaf1', + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty-1' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - leaf1: 'remote-pty-1', - leaf2: 'remote-pty-2' + [TEST_LEAF_1]: 'remote-pty-1', + [TEST_LEAF_2]: 'remote-pty-2' }) }) @@ -1603,14 +2130,14 @@ describe('Store', () => { targetId: 'ssh-1', ptyId: 'remote-pty-1', tabId: 'tab1', - leafId: 'leaf1', + leafId: TEST_LEAF_1, state: 'detached' }) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-2', tabId: 'tab1', - leafId: 'leaf2', + leafId: TEST_LEAF_2, state: 'detached' }) store.setWorkspaceSession({ @@ -1636,13 +2163,16 @@ describe('Store', () => { root: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf1' }, - second: { type: 'leaf', leafId: 'leaf2' }, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, - activeLeafId: 'leaf2', + activeLeafId: TEST_LEAF_2, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty-1', leaf2: 'remote-pty-2' } + ptyIdsByLeafId: { + [TEST_LEAF_1]: 'remote-pty-1', + [TEST_LEAF_2]: 'remote-pty-2' + } } } }) @@ -1667,16 +2197,16 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty-1' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - leaf1: 'remote-pty-1' + [TEST_LEAF_1]: 'remote-pty-1' }) }) @@ -1705,13 +2235,16 @@ describe('Store', () => { root: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf1' }, - second: { type: 'leaf', leafId: 'leaf2' }, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, - activeLeafId: 'leaf2', + activeLeafId: TEST_LEAF_2, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'local-pty-1', leaf2: 'local-pty-2' } + ptyIdsByLeafId: { + [TEST_LEAF_1]: 'local-pty-1', + [TEST_LEAF_2]: 'local-pty-2' + } } } }) @@ -1739,19 +2272,19 @@ describe('Store', () => { root: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf1' }, - second: { type: 'leaf', leafId: 'leaf2' }, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, - activeLeafId: 'leaf1', + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'local-pty-1' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - leaf1: 'local-pty-1' + [TEST_LEAF_1]: 'local-pty-1' }) }) @@ -1762,7 +2295,7 @@ describe('Store', () => { ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', - leafId: 'leaf1', + leafId: TEST_LEAF_1, state: 'detached' }) store.setWorkspaceSession({ @@ -1785,10 +2318,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) @@ -1828,10 +2361,10 @@ describe('Store', () => { }, terminalLayoutsByTabId: { tab1: { - root: { type: 'leaf', leafId: 'leaf1' }, - activeLeafId: 'leaf1', + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { leaf1: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 28e2804a3..00bb9bc44 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -37,8 +37,12 @@ import type { OnboardingChecklistState, OnboardingOutcome, OnboardingState, - TerminalPaneLayoutNode + TerminalPaneLayoutNode, + TerminalLayoutSnapshot, + TerminalTab, + WorkspaceSessionState } from '../shared/types' +import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -53,6 +57,11 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' +import { + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyPersistenceListener +} from './agent-hooks/migration-unsupported-pty-state' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' @@ -261,7 +270,7 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { ptyId: raw.ptyId, ...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}), ...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}), - ...(typeof raw.leafId === 'string' ? { leafId: raw.leafId } : {}), + ...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}), state, createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now, @@ -270,6 +279,527 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { } } +type LayoutLeafNormalization = { + snapshot: TerminalLayoutSnapshot + changed: boolean + leafIdByInputLeafId: Map +} + +function collectLayoutLeafCounts( + node: TerminalPaneLayoutNode, + counts: Map = new Map() +): Map { + if (node.type === 'leaf') { + counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) + return counts + } + collectLayoutLeafCounts(node.first, counts) + collectLayoutLeafCounts(node.second, counts) + return counts +} + +function collectLayoutLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + if (node.type === 'leaf') { + return [node.leafId] + } + return [...collectLayoutLeafIdsInOrder(node.first), ...collectLayoutLeafIdsInOrder(node.second)] +} + +function firstLayoutLeafId(node: TerminalPaneLayoutNode | null): string | null { + if (!node) { + return null + } + return node.type === 'leaf' ? node.leafId : firstLayoutLeafId(node.first) +} + +function layoutContainsLeafId(node: TerminalPaneLayoutNode | null, leafId: string): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutContainsLeafId(node.first, leafId) || layoutContainsLeafId(node.second, leafId) +} + +function cloneLayoutNode(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode { + if (node.type === 'leaf') { + return { type: 'leaf', leafId: node.leafId } + } + return { + ...node, + first: cloneLayoutNode(node.first), + second: cloneLayoutNode(node.second) + } +} + +function cloneLayoutWithLeafIds( + node: TerminalPaneLayoutNode, + leafIdByInputLeafId: Map, + duplicatedInputLeafIds: Set +): TerminalPaneLayoutNode { + if (node.type === 'leaf') { + return { + type: 'leaf', + leafId: duplicatedInputLeafIds.has(node.leafId) + ? randomUUID() + : (leafIdByInputLeafId.get(node.leafId) ?? randomUUID()) + } + } + return { + ...node, + first: cloneLayoutWithLeafIds(node.first, leafIdByInputLeafId, duplicatedInputLeafIds), + second: cloneLayoutWithLeafIds(node.second, leafIdByInputLeafId, duplicatedInputLeafIds) + } +} + +function remapLeafRecordForPersistence( + source: Record | undefined, + leafIdByInputLeafId: Map, + duplicatedInputLeafIds: Set +): Record | undefined { + if (!source) { + return undefined + } + const next: Record = {} + for (const [leafId, value] of Object.entries(source)) { + if (duplicatedInputLeafIds.has(leafId)) { + continue + } + const nextLeafId = leafIdByInputLeafId.get(leafId) + if (nextLeafId) { + next[nextLeafId] = value + } + } + return Object.keys(next).length > 0 ? next : undefined +} + +function leafRecordEquivalent( + left: Record | undefined, + right: Record | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}) + const rightRecord = right ?? {} + if (leftEntries.length !== Object.keys(rightRecord).length) { + return false + } + return leftEntries.every(([key, value]) => rightRecord[key] === value) +} + +function preserveMissingLeafRecordEntries( + priorRecord: Record | undefined, + incomingRecord: Record | undefined, + liveLeafIds: Set +): Record | undefined { + const preserved = Object.fromEntries( + Object.entries(priorRecord ?? {}).filter( + ([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined + ) + ) + const next = { ...preserved, ...incomingRecord } + return Object.keys(next).length > 0 ? next : undefined +} + +function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): string | undefined { + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return undefined +} + +function collectMigrationUnsupportedPtyEntries(args: { + session: WorkspaceSessionState + tabId: string + inputLayout: TerminalLayoutSnapshot + normalizedLayout: TerminalLayoutSnapshot + leafIdByInputLeafId: Map + sourceForPtyId: (ptyId: string) => 'local' | 'ssh' +}): MigrationUnsupportedPtyEntry[] { + const entries: MigrationUnsupportedPtyEntry[] = [] + const worktreeId = findWorktreeIdForTab(args.session, args.tabId) + const tab = worktreeId + ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) + : undefined + const pushEntry = (ptyId: string, leafId: string): void => { + if (!isTerminalLeafId(leafId)) { + return + } + let paneKey: string + try { + paneKey = makePaneKey(args.tabId, leafId) + } catch { + return + } + entries.push({ + ptyId, + ...(worktreeId ? { worktreeId } : {}), + tabId: args.tabId, + leafId, + paneKey, + reason: 'legacy-numeric-pane-key', + source: args.sourceForPtyId(ptyId), + updatedAt: Date.now() + }) + } + for (const [inputLeafId, ptyId] of Object.entries(args.inputLayout.ptyIdsByLeafId ?? {})) { + if (isTerminalLeafId(inputLeafId)) { + continue + } + const leafId = args.leafIdByInputLeafId.get(inputLeafId) + if (leafId) { + pushEntry(ptyId, leafId) + } + } + if ( + entries.length === 0 && + tab?.ptyId && + Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length === 0 + ) { + const fallbackLeafId = + args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) + if (fallbackLeafId) { + // Why: older single-pane sessions can reattach from tab.ptyId only, with + // no leaf binding to migrate. Their live shell env can still hold the + // legacy pane key, so surface the restart-required row for that PTY. + pushEntry(tab.ptyId, fallbackLeafId) + } + } + return entries +} + +function normalizeTerminalLayoutSnapshotForPersistence( + snapshot: TerminalLayoutSnapshot, + preferredLayout?: TerminalLayoutSnapshot +): LayoutLeafNormalization { + let inputSnapshot = snapshot + let changed = false + if (!inputSnapshot.root) { + if (!preferredLayout?.root) { + return { snapshot, changed: false, leafIdByInputLeafId: new Map() } + } + const root = cloneLayoutNode(preferredLayout.root) + const rootLeafIds = new Set(collectLayoutLeafIdsInOrder(root)) + const activeLeafId = + (inputSnapshot.activeLeafId && rootLeafIds.has(inputSnapshot.activeLeafId) + ? inputSnapshot.activeLeafId + : null) ?? + (preferredLayout.activeLeafId && rootLeafIds.has(preferredLayout.activeLeafId) + ? preferredLayout.activeLeafId + : null) ?? + firstLayoutLeafId(root) + const expandedLeafId = + (inputSnapshot.expandedLeafId && rootLeafIds.has(inputSnapshot.expandedLeafId) + ? inputSnapshot.expandedLeafId + : null) ?? + (preferredLayout.expandedLeafId && rootLeafIds.has(preferredLayout.expandedLeafId) + ? preferredLayout.expandedLeafId + : null) + inputSnapshot = { ...inputSnapshot, root, activeLeafId, expandedLeafId } + // Why: a debounced renderer writer can still hold the createTab-era empty + // layout after persistPtyBinding has already sync-flushed the UUID root. + changed = true + } + const inputRoot = inputSnapshot.root + if (!inputRoot) { + return { snapshot, changed: false, leafIdByInputLeafId: new Map() } + } + const counts = collectLayoutLeafCounts(inputRoot) + const duplicatedInputLeafIds = new Set( + Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([leafId]) => leafId) + ) + const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot) + const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root) + const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length + const leafIdByInputLeafId = new Map() + for (const [index, leafId] of inputLeafIdsInOrder.entries()) { + const count = counts.get(leafId) ?? 0 + if (count !== 1 || leafIdByInputLeafId.has(leafId)) { + changed = true + continue + } + if (isTerminalLeafId(leafId)) { + leafIdByInputLeafId.set(leafId, leafId) + continue + } + changed = true + const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined + leafIdByInputLeafId.set( + leafId, + preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID() + ) + } + const root = changed + ? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds) + : inputRoot + const activeLeafId = + inputSnapshot.activeLeafId && !duplicatedInputLeafIds.has(inputSnapshot.activeLeafId) + ? (leafIdByInputLeafId.get(inputSnapshot.activeLeafId) ?? firstLayoutLeafId(root)) + : inputSnapshot.activeLeafId === null + ? null + : firstLayoutLeafId(root) + const expandedLeafId = + inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId) + ? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null) + : null + const ptyIdsByLeafId = remapLeafRecordForPersistence( + inputSnapshot.ptyIdsByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) + const buffersByLeafId = remapLeafRecordForPersistence( + inputSnapshot.buffersByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) + const titlesByLeafId = remapLeafRecordForPersistence( + inputSnapshot.titlesByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) + const recordsChanged = + !leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) || + !leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) || + !leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId) + const metadataChanged = + activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId + if (!changed && !recordsChanged && !metadataChanged) { + return { snapshot, changed: false, leafIdByInputLeafId } + } + const { + ptyIdsByLeafId: _oldPtyIdsByLeafId, + buffersByLeafId: _oldBuffersByLeafId, + titlesByLeafId: _oldTitlesByLeafId, + ...snapshotWithoutLeafRecords + } = inputSnapshot + return { + snapshot: { + ...snapshotWithoutLeafRecords, + root, + activeLeafId, + expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + }, + changed: true, + leafIdByInputLeafId + } +} + +function normalizeWorkspaceSessionPaneIdentities( + session: WorkspaceSessionState, + priorLayoutsByTabId: Record = {}, + sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local' +): { + session: WorkspaceSessionState + changed: boolean + leafIdByInputLeafIdByTabId: Map> + leafIdByPtyIdByTabId: Map> + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] +} { + let changed = false + const leafIdByInputLeafIdByTabId = new Map>() + const leafIdByPtyIdByTabId = new Map>() + const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const terminalLayoutsByTabId: Record = {} + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const normalized = normalizeTerminalLayoutSnapshotForPersistence( + layout, + priorLayoutsByTabId[tabId] + ) + terminalLayoutsByTabId[tabId] = normalized.snapshot + leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) + migrationUnsupportedEntries.push( + ...collectMigrationUnsupportedPtyEntries({ + session, + tabId, + inputLayout: layout, + normalizedLayout: normalized.snapshot, + leafIdByInputLeafId: normalized.leafIdByInputLeafId, + sourceForPtyId + }) + ) + const leafIdByPtyId = new Map() + const duplicatePtyIds = new Set() + for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { + if (duplicatePtyIds.has(ptyId)) { + continue + } + if (leafIdByPtyId.has(ptyId)) { + leafIdByPtyId.delete(ptyId) + duplicatePtyIds.add(ptyId) + continue + } + leafIdByPtyId.set(ptyId, leafId) + } + leafIdByPtyIdByTabId.set(tabId, leafIdByPtyId) + changed ||= normalized.changed + } + return { + session: changed ? { ...session, terminalLayoutsByTabId } : session, + changed, + leafIdByInputLeafIdByTabId, + leafIdByPtyIdByTabId, + migrationUnsupportedEntries + } +} + +function remapSshRemotePtyLeaseLeafIds( + leases: SshRemotePtyLease[], + leafIdByInputLeafIdByTabId: Map>, + leafIdByPtyIdByTabId: Map> +): { leases: SshRemotePtyLease[]; changed: boolean } { + let changed = false + const nextLeases = leases.map((lease) => { + if (lease.leafId === undefined || isTerminalLeafId(lease.leafId)) { + return lease + } + const remappedLeafId = lease.tabId + ? leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId) + : undefined + const leafIdForPty = lease.tabId + ? leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId) + : undefined + changed = true + const nextLeafId = remappedLeafId ?? leafIdForPty + if (nextLeafId) { + return { ...lease, leafId: nextLeafId } + } + const next = { ...lease } + // Why: unmatched legacy leaf ids are ambiguous after migration; do not + // re-persist them as durable pane identity. + delete next.leafId + return next + }) + return { leases: nextLeases, changed } +} + +function normalizePersistedPaneIdentityState(state: PersistedState): { + state: PersistedState + changed: boolean + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] +} { + const sshPtyIds = new Set((state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) + const normalizedSession = normalizeWorkspaceSessionPaneIdentities( + state.workspaceSession, + {}, + (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') + ) + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + state.sshRemotePtyLeases ?? [], + normalizedSession.leafIdByInputLeafIdByTabId, + normalizedSession.leafIdByPtyIdByTabId + ) + const mergedMigrationUnsupportedEntries = mergeMigrationUnsupportedPtyEntries([ + ...(state.migrationUnsupportedPtyEntries ?? []), + ...normalizedSession.migrationUnsupportedEntries + ]) + const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( + state.migrationUnsupportedPtyEntries ?? [], + mergedMigrationUnsupportedEntries + ) + if (!normalizedSession.changed && !remappedLeases.changed && !migrationUnsupportedChanged) { + return { + state, + changed: false, + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + } + } + return { + state: { + ...state, + workspaceSession: normalizedSession.session, + sshRemotePtyLeases: remappedLeases.leases, + migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries + }, + changed: true, + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + } +} + +function mergeMigrationUnsupportedPtyEntries( + entries: MigrationUnsupportedPtyEntry[] +): MigrationUnsupportedPtyEntry[] { + const byPtyId = new Map() + for (const entry of entries) { + const existing = byPtyId.get(entry.ptyId) + if (!existing || existing.updatedAt <= entry.updatedAt) { + byPtyId.set(entry.ptyId, entry) + } + } + return [...byPtyId.values()] +} + +function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsupportedPtyEntry[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is MigrationUnsupportedPtyEntry => { + if (!entry || typeof entry !== 'object') { + return false + } + const candidate = entry as Partial + return ( + typeof candidate.ptyId === 'string' && + candidate.ptyId.length > 0 && + (candidate.worktreeId === undefined || typeof candidate.worktreeId === 'string') && + (candidate.tabId === undefined || typeof candidate.tabId === 'string') && + (candidate.leafId === undefined || isTerminalLeafId(candidate.leafId)) && + (candidate.paneKey === undefined || typeof candidate.paneKey === 'string') && + candidate.reason === 'legacy-numeric-pane-key' && + (candidate.source === 'local' || candidate.source === 'ssh') && + Number.isFinite(candidate.updatedAt) + ) + }) +} + +function migrationUnsupportedEntriesEqual( + left: MigrationUnsupportedPtyEntry[], + right: MigrationUnsupportedPtyEntry[] +): boolean { + if (left.length !== right.length) { + return false + } + const rightByPtyId = new Map(right.map((entry) => [entry.ptyId, entry])) + return left.every((entry) => { + const other = rightByPtyId.get(entry.ptyId) + return other ? JSON.stringify(entry) === JSON.stringify(other) : false + }) +} + +function createMinimalPersistedTerminalTab(args: { + worktreeId: string + tabId: string + ptyId: string + existingTabCount: number +}): TerminalTab { + const ordinal = args.existingTabCount + 1 + const defaultTitle = `Terminal ${ordinal}` + return { + id: args.tabId, + ptyId: args.ptyId, + worktreeId: args.worktreeId, + title: defaultTitle, + defaultTitle, + customTitle: null, + color: null, + sortOrder: args.existingTabCount, + createdAt: Date.now(), + pendingActivationSpawn: true + } +} + +function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState { + return structuredClone(session) +} + export class Store { private state: PersistedState private writeTimer: ReturnType | null = null @@ -278,7 +808,21 @@ export class Store { private gitUsernameCache = new Map() constructor() { - this.state = this.load() + const loaded = this.load() + const normalized = normalizePersistedPaneIdentityState(loaded) + this.state = normalized.state + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + setMigrationUnsupportedPtyPersistenceListener((entries) => { + this.state.migrationUnsupportedPtyEntries = entries + this.scheduleSave() + }) + if (normalized.changed) { + // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at + // the main persistence boundary so older renderer writes cannot revive them. + this.scheduleSave() + } } // Why (issue #1158): debounced writes fire as often as every 300ms during @@ -547,6 +1091,9 @@ export class Store { sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [], onboarding: (() => { @@ -809,6 +1356,18 @@ export class Store { } } + private flushOrThrow(): void { + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + // Why: bump writeGeneration so any in-flight async writeToDiskAsync skips + // its rename, preventing a stale snapshot from overwriting this sync write. + this.writeGeneration++ + this.pendingWrite = null + this.writeToDiskSync() + } + // ── Repos ────────────────────────────────────────────────────────── getRepos(): Repo[] { @@ -1304,6 +1863,24 @@ export class Store { // the durable binding and re-open the orphan window. Merge in any // existing bindings whenever the incoming snapshot's binding is empty. const prior = this.state.workspaceSession + const sshPtyIds = new Set((this.state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) + const normalized = normalizeWorkspaceSessionPaneIdentities( + session, + prior?.terminalLayoutsByTabId, + (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') + ) + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + session = normalized.session + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + this.state.sshRemotePtyLeases ?? [], + normalized.leafIdByInputLeafIdByTabId, + normalized.leafIdByPtyIdByTabId + ) + if (remappedLeases.changed) { + this.state.sshRemotePtyLeases = remappedLeases.leases + } if (session && prior) { const priorTabs = prior.tabsByWorktree ?? {} const nextTabs = session.tabsByWorktree ?? {} @@ -1368,6 +1945,24 @@ export class Store { ) if (Object.keys(restorableBindings).length > 0) { layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } + // Why: the same stale session write that drops ptyIdsByLeafId can + // also be from an older renderer that lacks UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } } } } @@ -1382,7 +1977,9 @@ export class Store { return } if (node.type === 'leaf') { - leafIds.add(node.leafId) + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } return } visit(node.first) @@ -1497,13 +2094,67 @@ export class Store { if (!session) { return } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) const tabs = session.tabsByWorktree?.[args.worktreeId] const tab = tabs?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = args.ptyId + } else { + // Why: pty:spawn can beat the debounced session writer for a newly + // created tab. Persist a minimal tab so hydration does not prune the + // crash-safe layout binding below as an orphaned tab id. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [args.worktreeId]: nextTabs + } + session.activeWorktreeId ??= args.worktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [args.worktreeId]: session.activeTabIdByWorktree?.[args.worktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: legacy renderer-local pane ids may arrive from older callers; keep + // them out of durable leaf-keyed layout state after the UUID migration. + try { + this.flushOrThrow() + } catch (err) { + this.state.workspaceSession = sessionBeforeBinding + throw err + } + return } const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { + if (!layout.root) { + // Why: createTab can persist an empty layout before TerminalPane mounts. + // The sync spawn binding must still leave a durable UUID root behind. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + // Why: splitPane publishes the new pane and starts pty:spawn before the + // debounced full layout snapshot reaches main. Add a minimal leaf so a + // crash in that window cannot make the new pane's binding unreachable. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [args.leafId]: args.ptyId @@ -1525,7 +2176,12 @@ export class Store { } } } - this.flush() + try { + this.flushOrThrow() + } catch (err) { + this.state.workspaceSession = sessionBeforeBinding + throw err + } } // ── SSH Targets ──────────────────────────────────────────────────── @@ -1575,16 +2231,21 @@ export class Store { Partial> ): void { this.state.sshRemotePtyLeases ??= [] + const normalizedLease = { ...lease } + if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) { + delete normalizedLease.leafId + } const now = Date.now() const existingIndex = this.state.sshRemotePtyLeases.findIndex( - (entry) => entry.targetId === lease.targetId && entry.ptyId === lease.ptyId + (entry) => + entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined const next: SshRemotePtyLease = { ...existing, - ...lease, - createdAt: existing?.createdAt ?? lease.createdAt ?? now, - updatedAt: lease.updatedAt ?? now + ...normalizedLease, + createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, + updatedAt: normalizedLease.updatedAt ?? now } if (existingIndex >= 0) { this.state.sshRemotePtyLeases[existingIndex] = next @@ -1637,6 +2298,20 @@ export class Store { this.flush() } + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + const leases = (this.state.sshRemotePtyLeases ?? []).filter( + (lease) => lease.targetId === targetId && lease.ptyId === ptyId + ) + const before = this.state.sshRemotePtyLeases?.length ?? 0 + this.clearSshRemotePtyBindingsForLeases(targetId, leases) + this.state.sshRemotePtyLeases = (this.state.sshRemotePtyLeases ?? []).filter( + (lease) => lease.targetId !== targetId || lease.ptyId !== ptyId + ) + if (this.state.sshRemotePtyLeases.length !== before) { + this.flush() + } + } + removeSshRemotePtyLeases(targetId: string): void { this.state.sshRemotePtyLeases ??= [] this.clearSshRemotePtyBindingsForTarget(targetId) @@ -1651,6 +2326,10 @@ export class Store { private clearSshRemotePtyBindingsForTarget(targetId: string): void { const leases = this.state.sshRemotePtyLeases?.filter((lease) => lease.targetId === targetId) + this.clearSshRemotePtyBindingsForLeases(targetId, leases ?? []) + } + + private clearSshRemotePtyBindingsForLeases(targetId: string, leases: SshRemotePtyLease[]): void { const session = this.state.workspaceSession if (!leases?.length || !session) { return @@ -1709,16 +2388,8 @@ export class Store { // ── Flush (for shutdown) ─────────────────────────────────────────── flush(): void { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - // Why: bump writeGeneration so any in-flight async writeToDiskAsync skips - // its rename, preventing a stale snapshot from overwriting this sync write. - this.writeGeneration++ - this.pendingWrite = null try { - this.writeToDiskSync() + this.flushOrThrow() } catch (err) { console.error('[persistence] Failed to flush state:', err) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d84f39353..0b89b417f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -138,6 +138,15 @@ const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' const TEST_WORKTREE_PATH = '/tmp/worktree-a' const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}` +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +function expectStablePaneKeyEnv(env: Record): string { + expect(env.ORCA_TAB_ID).toMatch(UUID_RE) + const leafId = env.ORCA_PANE_KEY?.slice(`${env.ORCA_TAB_ID}:`.length) + expect(leafId).toMatch(UUID_RE) + expect(env.ORCA_PANE_KEY).toBe(`${env.ORCA_TAB_ID}:${leafId}`) + return env.ORCA_PANE_KEY +} function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) @@ -952,24 +961,52 @@ describe('OrcaRuntimeService', () => { }) expect(result.handle).toMatch(/^term_/) expect(createTerminal).not.toHaveBeenCalled() - // Why: hook-based agent status keys off `${tabId}:${paneId}`, so main must + // Why: hook-based agent status keys off `${tabId}:${leafId}`, so main must // pre-allocate the tabId and stamp ORCA_PANE_KEY/TAB_ID/WORKTREE_ID into // the PTY env before spawn. The same tabId is then handed to the renderer - // via `revealTerminalSession` so adoption preserves attribution. See - // docs/cli-terminal-hook-pane-key.md. + // via `revealTerminalSession` so adoption preserves attribution. const spawnCall = spawn.mock.calls[0]?.[0] as { env?: Record } | undefined const spawnedEnv = spawnCall?.env ?? {} - expect(spawnedEnv.ORCA_TAB_ID).toMatch(/^[0-9a-f-]+$/) - expect(spawnedEnv.ORCA_PANE_KEY).toBe(`${spawnedEnv.ORCA_TAB_ID}:1`) + expectStablePaneKeyEnv(spawnedEnv) + const spawnedLeafId = spawnedEnv.ORCA_PANE_KEY.slice(`${spawnedEnv.ORCA_TAB_ID}:`.length) expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_WORKTREE_ID) expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { ptyId: 'pty-bg', title: 'worker', activate: false, - tabId: spawnedEnv.ORCA_TAB_ID + tabId: spawnedEnv.ORCA_TAB_ID, + leafId: spawnedLeafId }) }) + it('adopts renderer pane identity for remote runtime terminal creates', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-remote-runtime' + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + focus: false, + tabId, + leafId, + env: { + ORCA_PANE_KEY: `${tabId}:${leafId}`, + ORCA_TAB_ID: tabId + } + }) + + const spawnedEnv = + (spawn.mock.calls[0]?.[0] as { env?: Record } | undefined)?.env ?? {} + expect(spawnedEnv.ORCA_TAB_ID).toBe(tabId) + expect(spawnedEnv.ORCA_PANE_KEY).toBe(`${tabId}:${leafId}`) + }) + it('creates background terminal sessions while the renderer graph is unavailable', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) @@ -1025,13 +1062,14 @@ describe('OrcaRuntimeService', () => { }) const spawnCall = spawn.mock.calls[0]?.[0] as { env?: Record } | undefined const spawnedEnv = spawnCall?.env ?? {} - expect(spawnedEnv.ORCA_TAB_ID).toMatch(/^[0-9a-f-]+$/) - expect(spawnedEnv.ORCA_PANE_KEY).toBe(`${spawnedEnv.ORCA_TAB_ID}:1`) + expectStablePaneKeyEnv(spawnedEnv) + const spawnedLeafId = spawnedEnv.ORCA_PANE_KEY.slice(`${spawnedEnv.ORCA_TAB_ID}:`.length) expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { ptyId: 'pty-bg', title: null, activate: false, - tabId: spawnedEnv.ORCA_TAB_ID + tabId: spawnedEnv.ORCA_TAB_ID, + leafId: spawnedLeafId }) expect(warn).toHaveBeenCalledWith( expect.stringContaining('[terminal-create] failed to create inactive tab for pty-bg:'), @@ -1127,7 +1165,8 @@ describe('OrcaRuntimeService', () => { expect(revealTerminalSession).toHaveBeenLastCalledWith(TEST_WORKTREE_ID, { ptyId: 'pty-bg', title: 'worker', - tabId: expect.stringMatching(/^[0-9a-f-]+$/) + tabId: expect.stringMatching(UUID_RE), + leafId: expect.stringMatching(UUID_RE) }) }) @@ -1590,6 +1629,89 @@ describe('OrcaRuntimeService', () => { } }) + it('closes the matching mobile terminal UUID leaf without closing the whole tab', async () => { + const closeTerminal = vi.fn() + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal, + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const leftLeafId = '11111111-1111-4111-8111-111111111111' + const rightLeafId = '22222222-2222-4222-8222-222222222222' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + activeLeafId: rightLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: leftLeafId, + paneRuntimeId: 1, + ptyId: 'pty-left', + paneTitle: 'left' + }, + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: rightLeafId, + paneRuntimeId: 2, + ptyId: 'pty-right', + paneTitle: 'right' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-1::${rightLeafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${rightLeafId}`, + parentTabId: 'tab-1', + leafId: rightLeafId, + title: 'right', + isActive: true + } + ] + } + ] + }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, `tab-1::${rightLeafId}`) + + expect(kill).toHaveBeenCalledWith('pty-right') + expect(closeTerminal).not.toHaveBeenCalled() + }) + it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) const runtime = new OrcaRuntimeService(store) @@ -2255,12 +2377,12 @@ describe('OrcaRuntimeService', () => { command: 'bash /tmp/repo/.git/orca/setup-runner.sh', // Why: createTerminal stamps ORCA_PANE_KEY/TAB_ID/WORKTREE_ID into the // PTY env on top of the caller-supplied env so hook-based agent status - // can attribute hook events to a pane. See docs/cli-terminal-hook-pane-key.md. + // can attribute hook events to a stable pane. env: expect.objectContaining({ ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip', - ORCA_TAB_ID: expect.stringMatching(/^[0-9a-f-]+$/), - ORCA_PANE_KEY: expect.stringMatching(/^[0-9a-f-]+:1$/), + ORCA_TAB_ID: expect.stringMatching(UUID_RE), + ORCA_PANE_KEY: expect.any(String), ORCA_WORKTREE_ID: result.worktree.id }), worktreeId: result.worktree.id @@ -2268,12 +2390,14 @@ describe('OrcaRuntimeService', () => { ) const setupSpawnEnv = (spawn.mock.calls[1]?.[0] as { env?: Record } | undefined)?.env ?? {} - expect(setupSpawnEnv.ORCA_PANE_KEY).toBe(`${setupSpawnEnv.ORCA_TAB_ID}:1`) + expectStablePaneKeyEnv(setupSpawnEnv) + const setupLeafId = setupSpawnEnv.ORCA_PANE_KEY.slice(`${setupSpawnEnv.ORCA_TAB_ID}:`.length) expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, { ptyId: 'pty-setup', title: 'Setup', activate: false, - tabId: setupSpawnEnv.ORCA_TAB_ID + tabId: setupSpawnEnv.ORCA_TAB_ID, + leafId: setupLeafId }) }) @@ -2332,13 +2456,16 @@ describe('OrcaRuntimeService', () => { ) const initialSpawnEnv = (spawn.mock.calls[0]?.[0] as { env?: Record } | undefined)?.env ?? {} - expect(initialSpawnEnv.ORCA_TAB_ID).toMatch(/^[0-9a-f-]+$/) - expect(initialSpawnEnv.ORCA_PANE_KEY).toBe(`${initialSpawnEnv.ORCA_TAB_ID}:1`) + expectStablePaneKeyEnv(initialSpawnEnv) + const initialLeafId = initialSpawnEnv.ORCA_PANE_KEY.slice( + `${initialSpawnEnv.ORCA_TAB_ID}:`.length + ) expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, { ptyId: 'pty-created-worktree', title: null, activate: false, - tabId: initialSpawnEnv.ORCA_TAB_ID + tabId: initialSpawnEnv.ORCA_TAB_ID, + leafId: initialLeafId }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7eae9c84c..3e3dfd250 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -34,6 +34,7 @@ import { splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isPathInsideOrEqual, normalizeRuntimePathForComparison @@ -381,7 +382,13 @@ type RuntimeNotifier = { createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void revealTerminalSession?( worktreeId: string, - opts: { ptyId: string; title?: string | null; activate?: boolean; tabId?: string } + opts: { + ptyId: string + title?: string | null + activate?: boolean + tabId?: string + leafId?: string + } ): | Promise<{ tabId: string; title?: string | null }> | { tabId: string; title?: string | null } @@ -914,7 +921,7 @@ export class OrcaRuntimeService { lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', tabId: leaf.tabId, - paneKey: `${leaf.tabId}:${leaf.paneRuntimeId}` + paneKey: this.makeRuntimePaneKey(leaf) }) } @@ -1257,7 +1264,7 @@ export class OrcaRuntimeService { lastOutputAt: pty?.lastOutputAt ?? at, preview: pty?.preview ?? leaf.preview, tabId: leaf.tabId, - paneKey: `${leaf.tabId}:${leaf.paneRuntimeId}` + paneKey: this.makeRuntimePaneKey(leaf) }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' @@ -5842,7 +5849,14 @@ export class OrcaRuntimeService { async createTerminal( worktreeSelector?: string, - opts: { command?: string; env?: Record; title?: string; focus?: boolean } = {} + opts: { + command?: string + env?: Record + title?: string + focus?: boolean + tabId?: string + leafId?: string + } = {} ): Promise { if (opts.focus !== true) { if (!worktreeSelector) { @@ -5856,13 +5870,20 @@ export class OrcaRuntimeService { const preAllocatedHandle = this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys - // off `${tabId}:${paneId}` — without these vars set on the PTY, the + // off `${tabId}:${leafId}` — without these vars set on the PTY, the // hook payload arrives with an empty paneKey and the renderer cannot - // attribute the event. paneId is hard-coded to 1 because this path - // never splits and the renderer's nextPaneId starts at 1 for a fresh - // tab. See docs/cli-terminal-hook-pane-key.md. - const tabId = randomUUID() - const paneKey = `${tabId}:${FIRST_PANE_ID}` + // attribute the event. Use a stable UUID leaf because hooks reject the + // legacy numeric pane keys after the pane-id migration. + const hintedTabId = opts.tabId?.trim() + const canAdoptPaneIdentity = + hintedTabId !== undefined && + hintedTabId.length > 0 && + !hintedTabId.includes(':') && + opts.leafId !== undefined && + isTerminalLeafId(opts.leafId) + const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() + const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID() + const paneKey = makePaneKey(tabId, leafId) const env = { ...opts.env, ORCA_PANE_KEY: paneKey, @@ -5899,7 +5920,8 @@ export class OrcaRuntimeService { ptyId: result.id, title: opts.title ?? null, activate: false, - tabId + tabId, + leafId }) surface = 'visible' } catch (err) { @@ -6013,7 +6035,7 @@ export class OrcaRuntimeService { }) if (opts.activate !== false) { - this.notifier?.focusTerminal(reply.tabId, worktreeId, 'pane:1') + this.notifier?.focusTerminal(reply.tabId, worktreeId, null) } return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) } @@ -6029,7 +6051,7 @@ export class OrcaRuntimeService { throw new Error('terminal_handle_stale') } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` - const leafId = `pane:${FIRST_PANE_ID}` + const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${parentTabId}::${leafId}`, @@ -6245,10 +6267,12 @@ export class OrcaRuntimeService { if (!pty.pty.connected) { throw new Error('terminal_exited') } + const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '') const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { ptyId: pty.pty.ptyId, title: pty.pty.title ?? pty.pty.lastOscTitle, - ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}) + ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), + ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) }) return { handle, @@ -6257,7 +6281,7 @@ export class OrcaRuntimeService { } } const { leaf } = this.getLiveLeafForHandle(handle) - this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) + this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } } @@ -6661,6 +6685,14 @@ export class OrcaRuntimeService { return pty } + private makeRuntimePaneKey( + leaf: Pick + ): string { + return isTerminalLeafId(leaf.leafId) + ? makePaneKey(leaf.tabId, leaf.leafId) + : `${leaf.tabId}:${leaf.paneRuntimeId}` + } + private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { const existing = this.ptysById.get(ptyId) if (existing) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1fcbc4fae..81f59d22b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -318,7 +318,9 @@ const TerminalCreateParams = z.object({ command: OptionalString, env: z.record(z.string(), z.string()).optional(), title: OptionalString, - focus: z.unknown().optional() + focus: z.unknown().optional(), + tabId: OptionalString, + leafId: OptionalString }) const TerminalSplit = TerminalHandle.extend({ @@ -542,7 +544,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ command: params.command, env: params.env, title: params.title, - focus: params.focus === true + focus: params.focus === true, + tabId: params.tabId, + leafId: params.leafId }) }) }), diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 2aa5347ec..b1b6154e0 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -22,6 +22,10 @@ vi.mock('./ssh-relay-deploy', () => ({ const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') const { SshRelaySession } = await import('./ssh-relay-session') +const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333' + type CapturedStatus = { paneKey: string tabId?: string @@ -153,7 +157,7 @@ function captureAgentStatuses(events: CapturedStatus[]): void { function makeEnvelope(overrides: Partial = {}): AgentHookRelayEnvelope { return { source: 'codex', - paneKey: 'tab-ssh:0', + paneKey: `tab-ssh:${SSH_LEAF_ID}`, tabId: 'tab-ssh', worktreeId: 'wt-ssh', connectionId: null, @@ -217,7 +221,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { rows: 40, cwd: '/home/orca/project', env: { - ORCA_PANE_KEY: 'tab-ssh:0', + ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, ORCA_TAB_ID: 'tab-ssh', ORCA_WORKTREE_ID: 'wt-ssh' } @@ -228,7 +232,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(relay.ptySpawnRequests[0]).toMatchObject({ cwd: '/home/orca/project', env: { - ORCA_PANE_KEY: 'tab-ssh:0', + ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, ORCA_TAB_ID: 'tab-ssh', ORCA_WORKTREE_ID: 'wt-ssh' } @@ -238,7 +242,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { await waitForStatusCount(events, 1) expect(events[0]).toEqual({ - paneKey: 'tab-ssh:0', + paneKey: `tab-ssh:${SSH_LEAF_ID}`, tabId: 'tab-ssh', worktreeId: 'wt-ssh', connectionId: 'conn-fake', @@ -255,7 +259,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { relay = createFakeRelay() relay.replayEnvelopes.push( makeEnvelope({ - paneKey: 'tab-replay:0', + paneKey: `tab-replay:${REPLAY_LEAF_ID}`, tabId: 'tab-replay', worktreeId: 'wt-replay', payload: { @@ -278,7 +282,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { await waitForStatusCount(events, 1) expect(events[0]).toMatchObject({ - paneKey: 'tab-replay:0', + paneKey: `tab-replay:${REPLAY_LEAF_ID}`, tabId: 'tab-replay', worktreeId: 'wt-replay', connectionId: 'conn-replay', @@ -305,7 +309,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { relay.notifyAgentHook({ source: 'codex', - paneKey: 'tab-bad:0', + paneKey: `tab-bad:${BAD_LEAF_ID}`, connectionId: null, env: REMOTE_AGENT_HOOK_ENV, version: '1', diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cbfd97df3..afd62e898 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -266,8 +266,9 @@ function registerRuntimeWindowLifecycle( activate: opts.activate !== false, // Why: pre-minted tabId from main keeps the renderer's tab id aligned // with the paneKey baked into the PTY env at spawn time, so hook - // events route to the right slot. See docs/cli-terminal-hook-pane-key.md. - ...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}) + // events route to the right slot. + ...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}), + ...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}) }) }), splitTerminal: (tabId, paneRuntimeId, opts) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 015b5d353..8bcb4ce94 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -140,7 +140,10 @@ import type { ElectronAPI } from '@electron-toolkit/preload' import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' -import type { AgentStatusIpcPayload } from '../shared/agent-status-types' +import type { + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph, @@ -1328,6 +1331,7 @@ export type PreloadApi = { ptyId?: string activate?: boolean tabId?: string + leafId?: string }) => void ) => () => void onRequestTerminalCreate: ( @@ -1555,6 +1559,11 @@ export type PreloadApi = { onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise + /** Listen for PTYs that still use a legacy numeric pane key but have + * registry-backed UUID pane proof. */ + onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void + getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and the on-disk * last-status file. Fire-and-forget. */ drop: (paneKey: string) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index d8cfa6923..f007c51d4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -87,7 +87,10 @@ import type { PortForwardEntry, DetectedPort } from '../shared/ssh-types' -import type { AgentStatusIpcPayload } from '../shared/agent-status-types' +import type { + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../shared/agent-status-types' import type { SpeechModelManifest, SpeechModelState } from '../shared/speech-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { RefreshAgentsResult } from './api-types' @@ -2080,6 +2083,7 @@ const api = { ptyId?: string activate?: boolean tabId?: string + leafId?: string } ) => callback(data) ipcRenderer.on('ui:createTerminal', listener) @@ -2671,6 +2675,22 @@ const api = { * knows which tabs exist. */ getSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getSnapshot'), + onMigrationUnsupported: ( + callback: (entry: MigrationUnsupportedPtyEntry) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => + callback(entry) + ipcRenderer.on('agentStatus:migrationUnsupported', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) + }, + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => + callback(data) + ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) + }, + getMigrationUnsupportedSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), /** Drop the cached hook status for a paneKey on both sides — main-process * cache (lastStatusByPaneKey) and on-disk last-status file. Fired from * the renderer when the user dismisses a retained row so a relaunch diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index 29466576c..cfceac96c 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -28,6 +28,9 @@ import { } from '../shared/agent-hook-relay' import { AgentHookServer } from '../main/agent-hooks/server' +const LEAF_7 = '77777777-7777-4777-8777-777777777777' +const LEAF_9 = '99999999-9999-4999-8999-999999999999' + describe('Integration: relay hook server → mux → AgentHookServer.ingestRemote', () => { let tmpDir: string let mux: SshChannelMultiplexer @@ -128,7 +131,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ - paneKey: 'tab-7:0', + paneKey: `tab-7:${LEAF_7}`, tabId: 'tab-7', worktreeId: 'wt-7', env: 'remote', @@ -146,7 +149,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot await new Promise((r) => setImmediate(r)) } expect(events).toHaveLength(1) - expect(events[0].paneKey).toBe('tab-7:0') + expect(events[0].paneKey).toBe(`tab-7:${LEAF_7}`) expect(events[0].connectionId).toBe('conn-test') const payload = events[0].payload as { state: string; prompt: string; agentType: string } expect(payload.state).toBe('working') @@ -172,7 +175,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ - paneKey: 'tab-9:0', + paneKey: `tab-9:${LEAF_9}`, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cached' } }) }) @@ -198,6 +201,6 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot await new Promise((r) => setImmediate(r)) } expect(events).toHaveLength(2) - expect(events[1].paneKey).toBe('tab-9:0') + expect(events[1].paneKey).toBe(`tab-9:${LEAF_9}`) }) }) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 51fc04652..e24182441 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -4,6 +4,10 @@ import { tmpdir } from 'os' import { join } from 'path' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) describe('RelayAgentHookServer', () => { let dir: string @@ -36,7 +40,7 @@ describe('RelayAgentHookServer', () => { 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ - paneKey: 'tab-1:0', + paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', env: 'remote', @@ -48,7 +52,7 @@ describe('RelayAgentHookServer', () => { expect(forward).toHaveBeenCalledTimes(1) const envelope = forward.mock.calls[0][0] expect(envelope.source).toBe('claude') - expect(envelope.paneKey).toBe('tab-1:0') + expect(envelope.paneKey).toBe(PANE_KEY) expect(envelope.tabId).toBe('tab-1') expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') @@ -96,7 +100,7 @@ describe('RelayAgentHookServer', () => { 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ - paneKey: 'tab-1:0', + paneKey: PANE_KEY, tabId: 'tab-1', env: 'remote', version: '1', @@ -131,11 +135,11 @@ describe('RelayAgentHookServer', () => { 'X-Orca-Agent-Hook-Token': token }, body: JSON.stringify({ - paneKey: 'tab-1:0', + paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' } }) }) - server.clearPaneState('tab-1:0') + server.clearPaneState(PANE_KEY) forward.mockClear() const replayed = server.replayCachedPayloadsForPanes() expect(replayed).toBe(0) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1169ea6da..55c287bb8 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1304,8 +1304,7 @@ function Terminal(): React.JSX.Element | null { {(tabsByWorktree[worktree.id] ?? []).map((tab) => { const activityTerminalPortal = findActivityTerminalPortal( activityTerminalPortals, - worktree.id, - tab.id + { worktreeId: worktree.id, tabId: tab.id } ) const isActivityPortalTab = activityTerminalPortal !== null const isActiveTerminalTab = @@ -1325,7 +1324,7 @@ function Terminal(): React.JSX.Element | null { // Why: when portaled to Activity for a specific agent // pane, isolate that leaf so split siblings stay // hidden. Workspace renders pass null → no override. - isolatedPaneId={activityTerminalPortal?.paneId ?? null} + isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)} onCloseTab={() => handleCloseTab(tab.id)} /> diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index e21eb0ab9..c575c0dd5 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -212,9 +212,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // tab.ptyId is a wake-hint sessionId, not a liveness signal) and the jump // palette dot would lie green even though the sidebar dot is correctly grey. const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const prCache = useAppStore((s) => s.prCache) const issueCache = useAppStore((s) => s.issueCache) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeTabType = useAppStore((s) => s.activeTabType) const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) @@ -292,7 +294,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, agentStatusByPaneKey, runtimePaneTitlesByTabId, - ptyIdsByTabId + ptyIdsByTabId, + migrationUnsupportedByPtyId, + terminalLayoutsByTabId ) : switchableWorktreesForRows, [ @@ -303,7 +307,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, agentStatusByPaneKey, runtimePaneTitlesByTabId, - ptyIdsByTabId + ptyIdsByTabId, + migrationUnsupportedByPtyId, + terminalLayoutsByTabId ] ) @@ -320,7 +326,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, agentStatusByPaneKey, runtimePaneTitlesByTabId, - ptyIdsByTabId + ptyIdsByTabId, + migrationUnsupportedByPtyId, + terminalLayoutsByTabId ) }, [ allWorktrees, @@ -328,7 +336,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, agentStatusByPaneKey, runtimePaneTitlesByTabId, - ptyIdsByTabId + ptyIdsByTabId, + migrationUnsupportedByPtyId, + terminalLayoutsByTabId ]) // Why: browser rows need worktree lookups for repo badge colors, and browser diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index cec15d11c..d56c546be 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -17,6 +17,22 @@ import { getActivityThreadGroup, groupActivityThreadsByStatus } from './ActivityPrototypePage' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_ID_3 = '33333333-3333-4333-8333-333333333333' +const LEAF_ID_UNKNOWN = '44444444-4444-4444-8444-444444444444' +const LEAF_ID_A1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' +const LEAF_ID_B1 = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1' +const LEAF_ID_A2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +const PANE_KEY_2 = makePaneKey('tab-2', LEAF_ID_2) +const PANE_KEY_3 = makePaneKey('tab-3', LEAF_ID_3) +const UNKNOWN_PANE_KEY = makePaneKey('tab-unknown', LEAF_ID_UNKNOWN) +const PANE_KEY_A1 = makePaneKey('tab-a1', LEAF_ID_A1) +const PANE_KEY_B1 = makePaneKey('tab-b1', LEAF_ID_B1) +const PANE_KEY_A2 = makePaneKey('tab-a2', LEAF_ID_A2) function makeRepo(): Repo { return { @@ -89,7 +105,7 @@ function makeWorkingEntryWithPriorDone(): AgentStatusEntry { prompt: 'Second prompt', updatedAt: 2_000, stateStartedAt: 2_000, - paneKey: 'tab-1:1', + paneKey: PANE_KEY, terminalTitle: 'Claude', stateHistory: [ { @@ -108,7 +124,7 @@ function makeWorkingEntryWithoutHistory(): AgentStatusEntry { prompt: 'New run', updatedAt: 3_000, stateStartedAt: 3_000, - paneKey: 'tab-1:1', + paneKey: PANE_KEY, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude' @@ -122,7 +138,7 @@ function makeRetainedDoneEntry(tab: TerminalTab): RetainedAgentEntry { prompt: 'Retained prior run', updatedAt: 1_000, stateStartedAt: 1_000, - paneKey: 'tab-1:1', + paneKey: PANE_KEY, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude', @@ -169,7 +185,7 @@ describe('buildActivityEvents', () => { it('keeps a prior done event after the same pane starts working again', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': makeWorkingEntryWithPriorDone() + [PANE_KEY]: makeWorkingEntryWithPriorDone() }, now: 2_000 }) @@ -180,8 +196,8 @@ describe('buildActivityEvents', () => { timestamp: 1_000 }) expect(result.events[0].entry.prompt).toBe('First prompt') - expect(result.liveAgentByPaneKey['tab-1:1'].state).toBe('working') - expect(result.liveAgentByPaneKey['tab-1:1'].entry.prompt).toBe('Second prompt') + expect(result.liveAgentByPaneKey[PANE_KEY].state).toBe('working') + expect(result.liveAgentByPaneKey[PANE_KEY].entry.prompt).toBe('Second prompt') const threads = makeThreads(result) @@ -194,19 +210,19 @@ describe('buildActivityEvents', () => { it('does not keep showing a stale live agent as running', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': makeWorkingEntryWithPriorDone() + [PANE_KEY]: makeWorkingEntryWithPriorDone() }, now: 2_000 + AGENT_STATUS_STALE_AFTER_MS + 1 }) expect(result.events).toHaveLength(1) - expect(result.liveAgentByPaneKey['tab-1:1']).toBeUndefined() + expect(result.liveAgentByPaneKey[PANE_KEY]).toBeUndefined() }) it('creates a thread for a fresh running agent with no historical events', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': makeWorkingEntryWithoutHistory() + [PANE_KEY]: makeWorkingEntryWithoutHistory() } }) @@ -215,7 +231,7 @@ describe('buildActivityEvents', () => { expect(result.events).toHaveLength(0) expect(threads).toHaveLength(1) expect(threads[0]).toMatchObject({ - paneKey: 'tab-1:1', + paneKey: PANE_KEY, paneTitle: 'New run', currentAgentState: 'working', latestTimestamp: 3_000, @@ -233,7 +249,7 @@ describe('buildActivityEvents', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': entry + [PANE_KEY]: entry }, tab }) @@ -257,7 +273,7 @@ describe('buildActivityEvents', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': entry + [PANE_KEY]: entry } }) @@ -281,7 +297,7 @@ describe('buildActivityEvents', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': entry + [PANE_KEY]: entry } }) @@ -315,7 +331,7 @@ describe('buildActivityEvents', () => { const result = makeActivityResult({ retained: { - 'tab-1:1': makeRetainedDoneEntry(tab) + [PANE_KEY]: makeRetainedDoneEntry(tab) }, tab }) @@ -330,10 +346,10 @@ describe('buildActivityEvents', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': makeWorkingEntryWithoutHistory() + [PANE_KEY]: makeWorkingEntryWithoutHistory() }, retained: { - 'tab-1:1': makeRetainedDoneEntry(tab) + [PANE_KEY]: makeRetainedDoneEntry(tab) }, tab }) @@ -344,7 +360,7 @@ describe('buildActivityEvents', () => { timestamp: 1_000 }) expect(result.events[0].entry.prompt).toBe('Retained prior run') - expect(result.liveAgentByPaneKey['tab-1:1'].state).toBe('working') + expect(result.liveAgentByPaneKey[PANE_KEY].state).toBe('working') const threads = makeThreads(result) @@ -363,22 +379,22 @@ describe('buildActivityEvents', () => { const doneTab = { ...makeTab(), id: 'tab-3', ptyId: 'pty-3' } const result = buildActivityEvents({ agentStatusByPaneKey: { - 'tab-1:1': makeWorkingEntryWithoutHistory(), - 'tab-2:1': { + [PANE_KEY]: makeWorkingEntryWithoutHistory(), + [PANE_KEY_2]: { ...makeWorkingEntryWithoutHistory(), state: 'blocked', prompt: 'Needs approval', updatedAt: 4_000, stateStartedAt: 4_000, - paneKey: 'tab-2:1' + paneKey: PANE_KEY_2 }, - 'tab-3:1': { + [PANE_KEY_3]: { ...makeWorkingEntryWithoutHistory(), state: 'done', prompt: 'Finished work', updatedAt: 5_000, stateStartedAt: 5_000, - paneKey: 'tab-3:1' + paneKey: PANE_KEY_3 } }, retainedAgentsByPaneKey: {}, @@ -400,9 +416,9 @@ describe('buildActivityEvents', () => { expect(groups.map((group) => group.id)).toEqual(['working', 'blocked', 'done']) expect(groups.map((group) => group.threads.map((thread) => thread.paneKey))).toEqual([ - ['tab-1:1'], - ['tab-2:1'], - ['tab-3:1'] + [PANE_KEY], + [PANE_KEY_2], + [PANE_KEY_3] ]) }) }) @@ -425,16 +441,16 @@ describe('activity thread grouping', () => { } const { events, liveAgentByPaneKey } = buildActivityEvents({ agentStatusByPaneKey: { - 'tab-1:1': { + [PANE_KEY]: { ...sharedDone, - paneKey: 'tab-1:1', + paneKey: PANE_KEY, interrupted: true, updatedAt: 3_000, stateStartedAt: 3_000 }, - 'tab-2:1': { + [PANE_KEY_2]: { ...sharedDone, - paneKey: 'tab-2:1', + paneKey: PANE_KEY_2, interrupted: false, updatedAt: 2_000, stateStartedAt: 2_000 @@ -462,12 +478,12 @@ describe('activity thread grouping', () => { const tab = makeTabWithIds('tab-unknown', worktree.id) const { events, liveAgentByPaneKey } = buildActivityEvents({ agentStatusByPaneKey: { - 'tab-unknown:1': { + [UNKNOWN_PANE_KEY]: { state: 'done', prompt: 'Prompt', updatedAt: 1_000, stateStartedAt: 1_000, - paneKey: 'tab-unknown:1', + paneKey: UNKNOWN_PANE_KEY, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude' @@ -489,7 +505,7 @@ describe('activity thread grouping', () => { it('worktree and agent grouping use expected keys and labels', () => { const result = makeActivityResult({ entries: { - 'tab-1:1': makeWorkingEntryWithoutHistory() + [PANE_KEY]: makeWorkingEntryWithoutHistory() } }) const threads = makeThreads(result) @@ -513,32 +529,32 @@ describe('activity thread grouping', () => { const tabA2 = makeTabWithIds('tab-a2', wtA.id) const { events, liveAgentByPaneKey } = buildActivityEvents({ agentStatusByPaneKey: { - 'tab-a1:1': { + [PANE_KEY_A1]: { state: 'done', prompt: 'A1', updatedAt: 3_000, stateStartedAt: 3_000, - paneKey: 'tab-a1:1', + paneKey: PANE_KEY_A1, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude' }, - 'tab-b1:1': { + [PANE_KEY_B1]: { state: 'done', prompt: 'B1', updatedAt: 2_000, stateStartedAt: 2_000, - paneKey: 'tab-b1:1', + paneKey: PANE_KEY_B1, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude' }, - 'tab-a2:1': { + [PANE_KEY_A2]: { state: 'done', prompt: 'A2', updatedAt: 1_000, stateStartedAt: 1_000, - paneKey: 'tab-a2:1', + paneKey: PANE_KEY_A2, terminalTitle: 'Claude', stateHistory: [], agentType: 'claude' @@ -558,8 +574,8 @@ describe('activity thread grouping', () => { const groups = buildActivityThreadGroups(threads, 'worktree') expect(groups.map((group) => group.key)).toEqual(['worktree:wt-a', 'worktree:wt-b']) - expect(groups[0].threads.map((thread) => thread.paneKey)).toEqual(['tab-a1:1', 'tab-a2:1']) - expect(groups[1].threads.map((thread) => thread.paneKey)).toEqual(['tab-b1:1']) + expect(groups[0].threads.map((thread) => thread.paneKey)).toEqual([PANE_KEY_A1, PANE_KEY_A2]) + expect(groups[1].threads.map((thread) => thread.paneKey)).toEqual([PANE_KEY_B1]) }) it('returns no groups for empty thread input', () => { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 001fb45bf..6188cadfb 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -58,8 +58,11 @@ import { type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusState, - type AgentType + type AgentType, + type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' @@ -77,6 +80,7 @@ type ActivityEvent = { tab: TerminalTab agentType: AgentType agentAlive: boolean + migrationUnsupportedPtyId?: string unread: boolean } @@ -91,8 +95,8 @@ type ActivityLiveAgentSnapshot = { } // Why (per-pane thread): the activity feed is keyed on the agent pane (a -// terminal tab + pane id) rather than on the workspace, so the left list -// shows one entry per agent. paneKey is the stable identity (`${tabId}:${paneId}`). +// terminal tab + stable leaf id) rather than on the workspace, so the left list +// shows one entry per agent. paneKey is the durable identity (`${tabId}:${leafId}`). type AgentPaneThread = { paneKey: string paneTitle: string @@ -106,6 +110,7 @@ type AgentPaneThread = { latestTimestamp: number latestEvent: ActivityEvent | null events: ActivityEvent[] + migrationUnsupportedPtyId?: string unread: boolean } @@ -119,13 +124,14 @@ type ActivityThreadGroup = { type ActivityTerminalPortalReadiness = { target: HTMLElement | null - tabId: string | null - ready: boolean + paneKey: string | null + status: 'loading' | 'ready' | 'unavailable' } type ActivityTerminalPortalDomStatus = { hasSelectedRoot: boolean ready: boolean + unavailable: boolean } type ActivityTerminalPortalSlotId = 'primary' | 'secondary' @@ -168,6 +174,43 @@ function formatRelativeTime(timestamp: number): string { return relativeTimeFormatter.format(diffDays, 'day') } +function findActivityTerminalPane( + root: HTMLElement, + leafId: string +): { foundAnyPane: boolean; pane: HTMLElement | null } { + let foundAnyPane = false + for (const candidate of root.querySelectorAll('[data-leaf-id]')) { + foundAnyPane = true + if (candidate.dataset.leafId === leafId) { + return { foundAnyPane, pane: candidate } + } + } + return { foundAnyPane, pane: null } +} + +function hasInlineDisplayNoneBetween(element: HTMLElement, root: HTMLElement): boolean { + let current: HTMLElement | null = element + while (current) { + if (current.style.display === 'none') { + return true + } + if (current === root) { + return false + } + current = current.parentElement + } + return false +} + +function hasUnhiddenSiblingPane(root: HTMLElement, selectedPane: HTMLElement): boolean { + for (const candidate of root.querySelectorAll('[data-leaf-id]')) { + if (candidate !== selectedPane && !hasInlineDisplayNoneBetween(candidate, root)) { + return true + } + } + return false +} + function truncatePreservingSurrogates(value: string, maxLength: number): string { if (value.length <= maxLength) { return value @@ -195,50 +238,70 @@ export function activityThreadResponseRenderPreview({ ).trimEnd()}...` } -function paneIdFromPaneKey(paneKey: string): number | null { - const colon = paneKey.indexOf(':') - const tail = colon > 0 ? paneKey.slice(colon + 1) : '' - const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN - return Number.isFinite(parsed) && parsed > 0 ? parsed : null -} - function getSelectedActivityTerminalPortalStatus( target: HTMLElement, - tabId: string + paneKey: string ): ActivityTerminalPortalDomStatus { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { hasSelectedRoot: false, ready: false, unavailable: true } + } let selectedRoot: HTMLElement | null = null for (const candidate of target.querySelectorAll('[data-terminal-tab-id]')) { - if (candidate.dataset.terminalTabId === tabId) { + if (candidate.dataset.terminalTabId === parsed.tabId) { selectedRoot = candidate break } } if (!selectedRoot) { - return { hasSelectedRoot: false, ready: false } + return { hasSelectedRoot: false, ready: false, unavailable: false } } + + const { foundAnyPane, pane: selectedPane } = findActivityTerminalPane(selectedRoot, parsed.leafId) + if (!selectedPane) { + return { hasSelectedRoot: true, ready: false, unavailable: foundAnyPane } + } + + const unavailable = hasInlineDisplayNoneBetween(selectedPane, selectedRoot) + const hasUnisolatedSibling = hasUnhiddenSiblingPane(selectedRoot, selectedPane) + const isVisibleRoot = + !unavailable && (selectedPane.offsetParent !== null || selectedPane.getClientRects().length > 0) const hasPtyBinding = - selectedRoot.hasAttribute('data-pty-id') || - selectedRoot.querySelector('[data-pty-id]') !== null - const hasXtermScreen = selectedRoot.querySelector('.xterm-screen') !== null - return { hasSelectedRoot: true, ready: hasPtyBinding && hasXtermScreen } + selectedPane.hasAttribute('data-pty-id') || + selectedPane.querySelector('[data-pty-id]') !== null + const hasXtermScreen = selectedPane.querySelector('.xterm-screen') !== null + return { + hasSelectedRoot: true, + ready: isVisibleRoot && !hasUnisolatedSibling && hasPtyBinding && hasXtermScreen, + unavailable + } } -function useActivityTerminalPortalReadiness( +function useActivityTerminalPortalStatus( target: HTMLElement | null, - tabId: string | null -): boolean { + paneKey: string | null, + forceUnavailable = false +): ActivityTerminalPortalReadiness['status'] { const [readiness, setReadiness] = useState({ target: null, - tabId: null, - ready: false + paneKey: null, + status: 'loading' }) useLayoutEffect(() => { - if (!target || !tabId) { + if (!target || !paneKey) { setReadiness((prev) => - prev.target === null && prev.tabId === null && !prev.ready + prev.target === null && prev.paneKey === null && prev.status === 'loading' ? prev - : { target: null, tabId: null, ready: false } + : { target: null, paneKey: null, status: 'loading' } + ) + return + } + if (forceUnavailable) { + setReadiness((prev) => + prev.target === target && prev.paneKey === paneKey && prev.status === 'unavailable' + ? prev + : { target, paneKey, status: 'unavailable' } ) return } @@ -247,11 +310,11 @@ function useActivityTerminalPortalReadiness( let readyFrame: number | null = null let sawUnreadySelectedRoot = false - const updateReadiness = (ready: boolean): void => { + const updateReadiness = (status: ActivityTerminalPortalReadiness['status']): void => { setReadiness((prev) => - prev.target === target && prev.tabId === tabId && prev.ready === ready + prev.target === target && prev.paneKey === paneKey && prev.status === status ? prev - : { target, tabId, ready } + : { target, paneKey, status } ) } @@ -263,11 +326,16 @@ function useActivityTerminalPortalReadiness( } const checkReadiness = (): void => { - const status = getSelectedActivityTerminalPortalStatus(target, tabId) + const status = getSelectedActivityTerminalPortalStatus(target, paneKey) + if (status.unavailable) { + cancelReadyFrame() + updateReadiness('unavailable') + return + } if (status.ready) { if (!sawUnreadySelectedRoot) { cancelReadyFrame() - updateReadiness(true) + updateReadiness('ready') return } if (readyFrame !== null) { @@ -278,8 +346,8 @@ function useActivityTerminalPortalReadiness( // frame without moving terminal lifecycle work into global layout effects. readyFrame = requestAnimationFrame(() => { readyFrame = null - if (!disposed && getSelectedActivityTerminalPortalStatus(target, tabId).ready) { - updateReadiness(true) + if (!disposed && getSelectedActivityTerminalPortalStatus(target, paneKey).ready) { + updateReadiness('ready') } }) return @@ -288,10 +356,10 @@ function useActivityTerminalPortalReadiness( sawUnreadySelectedRoot = true } cancelReadyFrame() - updateReadiness(false) + updateReadiness('loading') } - updateReadiness(false) + updateReadiness('loading') checkReadiness() const observer = new MutationObserver(checkReadiness) @@ -299,7 +367,7 @@ function useActivityTerminalPortalReadiness( childList: true, subtree: true, attributes: true, - attributeFilter: ['data-terminal-tab-id', 'data-pty-id'] + attributeFilter: ['data-terminal-tab-id', 'data-leaf-id', 'data-pty-id', 'style'] }) return () => { @@ -307,9 +375,9 @@ function useActivityTerminalPortalReadiness( cancelReadyFrame() observer.disconnect() } - }, [target, tabId]) + }, [target, paneKey, forceUnavailable]) - return readiness.target === target && readiness.tabId === tabId && readiness.ready + return readiness.target === target && readiness.paneKey === paneKey ? readiness.status : 'loading' } function otherActivityTerminalSlot( @@ -440,6 +508,7 @@ function appendActivityEvent(args: { agentType: AgentType agentAlive: boolean acknowledgedAt: number + migrationUnsupportedPtyId?: string }): void { const id = `agent:${args.entry.paneKey}:${args.state}:${args.timestamp}` if (args.seenEventIds.has(id)) { @@ -456,6 +525,7 @@ function appendActivityEvent(args: { tab: args.tab, agentType: args.agentType, agentAlive: args.agentAlive, + migrationUnsupportedPtyId: args.migrationUnsupportedPtyId, unread: args.acknowledgedAt < args.timestamp }) } @@ -470,6 +540,7 @@ function appendActivityEventsForEntry(args: { agentType: AgentType agentAlive: boolean acknowledgedAt: number + migrationUnsupportedPtyId?: string }): void { // Why: Activity is an append-only history surface. When a user continues in // the same terminal pane, the live entry moves done→working; stateHistory is @@ -498,6 +569,7 @@ function appendActivityEventsForEntry(args: { export function buildActivityEvents(args: { agentStatusByPaneKey: Record + migrationUnsupportedByPtyId?: Record retainedAgentsByPaneKey: Record tabsByWorktree: Record worktreeMap: Map @@ -521,12 +593,11 @@ export function buildActivityEvents(args: { } for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) { - const separatorIndex = paneKey.indexOf(':') - if (separatorIndex <= 0) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { continue } - const tabId = paneKey.slice(0, separatorIndex) - const context = tabContext.get(tabId) + const context = tabContext.get(parsed.tabId) if (!context) { continue } @@ -559,7 +630,47 @@ export function buildActivityEvents(args: { }) } + for (const unsupported of Object.values(args.migrationUnsupportedByPtyId ?? {})) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (!entry) { + continue + } + const parsed = parsePaneKey(entry.paneKey) + if (!parsed) { + continue + } + const context = tabContext.get(parsed.tabId) + if (!context) { + continue + } + const ackAt = args.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0 + liveAgentByPaneKey[entry.paneKey] = { + state: 'blocked', + timestamp: entry.stateStartedAt, + worktree: context.worktree, + repo: args.repoMap.get(context.worktree.repoId) ?? null, + entry, + tab: context.tab, + agentType: entry.agentType ?? 'unknown' + } + appendActivityEventsForEntry({ + events, + seenEventIds, + worktree: context.worktree, + repo: args.repoMap.get(context.worktree.repoId) ?? null, + entry, + tab: context.tab, + agentType: entry.agentType ?? 'unknown', + agentAlive: false, + acknowledgedAt: ackAt, + migrationUnsupportedPtyId: unsupported.ptyId + }) + } + for (const [paneKey, retained] of Object.entries(args.retainedAgentsByPaneKey)) { + if (!parsePaneKey(paneKey)) { + continue + } const worktree = args.worktreeMap.get(retained.worktreeId) if (!worktree) { continue @@ -618,12 +729,15 @@ export function buildAgentPaneThreads(args: { latestTimestamp: event.timestamp, latestEvent: event, events: [event], + migrationUnsupportedPtyId: event.migrationUnsupportedPtyId, unread: event.unread }) continue } existing.events.push(event) existing.unread = existing.unread || event.unread + existing.migrationUnsupportedPtyId = + existing.migrationUnsupportedPtyId ?? event.migrationUnsupportedPtyId if (!existing.latestEvent || event.timestamp > existing.latestEvent.timestamp) { existing.latestEvent = event existing.paneTitle = paneTitleForEvent(event) @@ -1090,6 +1204,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { const storeData = useAppStore( useShallow((s) => ({ agentStatusByPaneKey: s.agentStatusByPaneKey, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, tabsByWorktree: s.tabsByWorktree, worktreeMap: getWorktreeMapFromState(s), @@ -1108,6 +1223,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { () => buildActivityEvents({ agentStatusByPaneKey: storeData.agentStatusByPaneKey, + migrationUnsupportedByPtyId: storeData.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: storeData.retainedAgentsByPaneKey, tabsByWorktree: storeData.tabsByWorktree, worktreeMap: storeData.worktreeMap, @@ -1199,10 +1315,20 @@ export default function ActivityPrototypePage(): React.JSX.Element { } satisfies Record const activePortalTargetEl = portalTargetBySlot[activePortalSlotId] const inactivePortalTargetEl = portalTargetBySlot[inactivePortalSlotId] - const visibleTabId = visibleThread?.tab.id ?? null - const stagedTabId = stagedThread?.tab.id ?? null - const visiblePortalReady = useActivityTerminalPortalReadiness(activePortalTargetEl, visibleTabId) - const stagedPortalReady = useActivityTerminalPortalReadiness(inactivePortalTargetEl, stagedTabId) + const visiblePortalStatus = useActivityTerminalPortalStatus( + activePortalTargetEl, + visibleThread?.paneKey ?? null, + visibleThread?.migrationUnsupportedPtyId !== undefined + ) + const stagedPortalStatus = useActivityTerminalPortalStatus( + inactivePortalTargetEl, + stagedThread?.paneKey ?? null, + stagedThread?.migrationUnsupportedPtyId !== undefined + ) + const visiblePortalReady = visiblePortalStatus === 'ready' + const visiblePortalUnavailable = visiblePortalStatus === 'unavailable' + const stagedPortalReady = stagedPortalStatus === 'ready' + const stagedPortalUnavailable = stagedPortalStatus === 'unavailable' const showTerminalLoadingLabel = useActivityTerminalLoadingLabel( Boolean(visibleThread && !stagedThread && !visiblePortalReady) ) @@ -1232,20 +1358,24 @@ export default function ActivityPrototypePage(): React.JSX.Element { if (visibleThread && activePortalTargetEl) { descriptors.push({ slotId: activePortalSlotId, + requestToken: `${activePortalSlotId}:${visibleThread.paneKey}`, target: activePortalTargetEl, worktreeId: visibleThread.worktree.id, tabId: visibleThread.tab.id, - paneId: paneIdFromPaneKey(visibleThread.paneKey), + paneKey: visibleThread.paneKey, + forceUnavailable: visibleThread.migrationUnsupportedPtyId !== undefined, active: true }) } if (stagedThread && inactivePortalTargetEl) { descriptors.push({ slotId: inactivePortalSlotId, + requestToken: `${inactivePortalSlotId}:${stagedThread.paneKey}`, target: inactivePortalTargetEl, worktreeId: stagedThread.worktree.id, tabId: stagedThread.tab.id, - paneId: paneIdFromPaneKey(stagedThread.paneKey), + paneKey: stagedThread.paneKey, + forceUnavailable: stagedThread.migrationUnsupportedPtyId !== undefined, active: false }) } @@ -1264,7 +1394,9 @@ export default function ActivityPrototypePage(): React.JSX.Element { setDisplayedPaneKey(null) return } - if (stagedThread && stagedPortalReady) { + if (stagedThread && (stagedPortalReady || stagedPortalUnavailable)) { + // Why: a stale selected pane should replace the old terminal with the + // unavailable state, not leave the previous pane visible under the new row. setActivePortalSlotId(inactivePortalSlotId) setDisplayedPaneKey(stagedThread.paneKey) return @@ -1276,6 +1408,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { inactivePortalSlotId, selectedHasLiveTab, selectedThread, + stagedPortalUnavailable, stagedPortalReady, stagedThread, visiblePortalReady, @@ -1325,15 +1458,44 @@ export default function ActivityPrototypePage(): React.JSX.Element { state.setActiveWorktree(thread.worktree.id) } state.setActiveTabType('terminal') - activateTabAndFocusPane(thread.tab.id, paneIdFromPaneKey(thread.paneKey)) + const parsed = parsePaneKey(thread.paneKey) + activateTabAndFocusPane( + thread.tab.id, + parsed && parsed.tabId === thread.tab.id ? parsed.leafId : null + ) } const selectThread = (thread: AgentPaneThread): void => { setSelectedPaneKey(thread.paneKey) - markThreadRead(thread) activateThreadTerminal(thread) } + useEffect(() => { + if ( + !selectedThread || + !selectedThread.unread || + stagedThread || + selectedThread.paneKey !== selectedPaneKey + ) { + return + } + const selectedThreadHasDetailOnlyView = + !selectedHasLiveTab || selectedThread.migrationUnsupportedPtyId !== undefined + const selectedThreadIsVisibleTerminal = + visibleThread?.paneKey === selectedPaneKey && visiblePortalReady + if (selectedThreadHasDetailOnlyView || selectedThreadIsVisibleTerminal) { + storeData.acknowledgeAgents([selectedThread.paneKey]) + } + }, [ + selectedHasLiveTab, + selectedPaneKey, + selectedThread, + stagedThread, + storeData, + visiblePortalReady, + visibleThread + ]) + const jumpToWorkspace = (thread: AgentPaneThread): void => { markThreadRead(thread) activateAndRevealWorktree(thread.worktree.id) @@ -1566,7 +1728,12 @@ export default function ActivityPrototypePage(): React.JSX.Element { className="pointer-events-none absolute inset-0 z-20 bg-editor-surface" aria-hidden="true" > - {showTerminalLoadingLabel ? ( + {visiblePortalUnavailable ? ( +
+ + Terminal unavailable +
+ ) : showTerminalLoadingLabel ? (
Connecting terminal... diff --git a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx index 6fd95fdec..42dafa48f 100644 --- a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx +++ b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' // Why: keep the unread accumulator local; "Mark all read" moved to the // thread-list overflow menu in ActivityPrototypePage so it lives next to @@ -34,6 +35,12 @@ function useActivityUnreadCount(): number { for (const [paneKey, retained] of Object.entries(s.retainedAgentsByPaneKey)) { accumulate(retained.entry, s.acknowledgedAgentsByPaneKey[paneKey] ?? 0) } + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (entry) { + accumulate(entry, s.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0) + } + } return count }) } diff --git a/src/renderer/src/components/activity/activity-terminal-portal.ts b/src/renderer/src/components/activity/activity-terminal-portal.ts index 5f7a342e4..c9c220322 100644 --- a/src/renderer/src/components/activity/activity-terminal-portal.ts +++ b/src/renderer/src/components/activity/activity-terminal-portal.ts @@ -2,14 +2,15 @@ import { useLayoutEffect, useState } from 'react' export type ActivityTerminalPortalTarget = { slotId: string + requestToken: string target: HTMLElement worktreeId: string tabId: string - // Why: each Activity thread is keyed on a single agent pane within a tab. - // Carrying paneId here lets TerminalPane isolate that pane visually - // (hiding split siblings) without touching the user-facing expanded-pane - // state or the persisted layout snapshot. - paneId: number | null + // Why: each Activity thread targets one stable terminal leaf inside a tab. + // Carry the durable paneKey across this boundary; TerminalPane resolves it + // to the current numeric PaneManager handle immediately before isolation. + paneKey: string + forceUnavailable?: boolean active: boolean } @@ -56,10 +57,35 @@ export function useActivityTerminalPortals(enabled: boolean): ActivityTerminalPo export function findActivityTerminalPortal( targets: ActivityTerminalPortalTarget[], - worktreeId: string, - tabId: string + query: { + worktreeId: string + tabId: string + slotId?: string + paneKey?: string + requestToken?: string + } ): ActivityTerminalPortalTarget | null { + const matchingTab = targets.filter( + (target) => target.worktreeId === query.worktreeId && target.tabId === query.tabId + ) + if ( + query.slotId !== undefined || + query.paneKey !== undefined || + query.requestToken !== undefined + ) { + const exact = matchingTab.find( + (target) => + (query.slotId === undefined || target.slotId === query.slotId) && + (query.paneKey === undefined || target.paneKey === query.paneKey) && + (query.requestToken === undefined || target.requestToken === query.requestToken) + ) + if (exact) { + return exact + } + } return ( - targets.find((target) => target.worktreeId === worktreeId && target.tabId === tabId) ?? null + matchingTab.find((target) => target.active) ?? + (matchingTab.length === 1 ? matchingTab[0] : null) ?? + null ) } diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 1385f8a9d..634bfb98e 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -5,9 +5,12 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry, type AgentStatusState, - type AgentType + type AgentType, + type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { Repo, Worktree, TerminalTab } from '../../../../shared/types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' // ─── Shared data types ──────────────────────────────────────────────────────── @@ -96,25 +99,40 @@ function buildDashboardData( worktreesByRepo: Record, tabsByWorktree: Record, agentStatusByPaneKey: Record, + migrationUnsupportedByPtyId: Record, now: number ): DashboardRepoGroup[] { // Why: build a tabId -> entries index once per computation instead of // re-scanning every agent status entry inside the per-tab loop. paneKey is - // formatted as `${tabId}:${paneId}`; splitting on the first ':' lets us - // bucket entries by tab in a single O(N) pass, turning the per-worktree - // build from O(tabs × statuses) into O(tabs). + // formatted as `${tabId}:${leafId}`; parsePaneKey also drops legacy numeric + // suffixes so stale rows do not remain routable after pane replay. const entriesByTabId = new Map() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - const colonIndex = paneKey.indexOf(':') - if (colonIndex === -1) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { continue } - const tabId = paneKey.slice(0, colonIndex) - const bucket = entriesByTabId.get(tabId) + const bucket = entriesByTabId.get(parsed.tabId) if (bucket) { bucket.push(entry) } else { - entriesByTabId.set(tabId, [entry]) + entriesByTabId.set(parsed.tabId, [entry]) + } + } + for (const unsupported of Object.values(migrationUnsupportedByPtyId)) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (!entry) { + continue + } + const parsed = parsePaneKey(entry.paneKey) + if (!parsed) { + continue + } + const bucket = entriesByTabId.get(parsed.tabId) + if (bucket) { + bucket.push(entry) + } else { + entriesByTabId.set(parsed.tabId, [entry]) } } @@ -143,6 +161,7 @@ export function useDashboardData(): DashboardRepoGroup[] { const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) // Why: agentStatusEpoch is included in the dependency array (but not in the // computation itself) so the memo recomputes when freshness boundaries expire, // even if no new PTY data arrives. @@ -154,8 +173,22 @@ export function useDashboardData(): DashboardRepoGroup[] { // freshness boundary crosses, driving re-evaluation without coupling to // wall-clock time directly. () => - buildDashboardData(repos, worktreesByRepo, tabsByWorktree, agentStatusByPaneKey, Date.now()), + buildDashboardData( + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + Date.now() + ), // eslint-disable-next-line react-hooks/exhaustive-deps - [repos, worktreesByRepo, tabsByWorktree, agentStatusByPaneKey, agentStatusEpoch] + [ + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + agentStatusEpoch + ] ) } diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index bb3d0f827..8a68b3638 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -8,6 +8,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from the live status set. @@ -30,11 +31,7 @@ type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & { } function paneKeyTabId(paneKey: string): string | null { - const colonIndex = paneKey.indexOf(':') - if (colonIndex <= 0) { - return null - } - return paneKey.slice(0, colonIndex) + return parsePaneKey(paneKey)?.tabId ?? null } function buildLiveTabIndex(args: { diff --git a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts index beb255183..c87c4612b 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts @@ -5,11 +5,16 @@ import { type AgentStatusState } from '../../../../shared/agent-status-types' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildRetainedAgentsSyncSignature, buildRetainedAgentsSyncSnapshot } from './useRetainedAgents' +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const ACTIVE_PANE_KEY = makePaneKey('tab-active', '22222222-2222-4222-8222-222222222222') +const ARCHIVED_PANE_KEY = makePaneKey('tab-archived', '33333333-3333-4333-8333-333333333333') + function makeRepo(): Repo { return { id: 'repo-1', @@ -95,8 +100,8 @@ describe('buildRetainedAgentsSyncSignature', () => { it('ignores fresh same-state working ping details but changes on state transitions', () => { const first = buildRetainedAgentsSyncSignature( makeSyncInputs({ - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [PANE_KEY]: makeEntry({ + paneKey: PANE_KEY, state: 'working', updatedAt: 1_000, stateStartedAt: 1_000, @@ -107,8 +112,8 @@ describe('buildRetainedAgentsSyncSignature', () => { ) const sameState = buildRetainedAgentsSyncSignature( makeSyncInputs({ - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [PANE_KEY]: makeEntry({ + paneKey: PANE_KEY, state: 'working', updatedAt: 2_000, stateStartedAt: 1_000, @@ -119,8 +124,8 @@ describe('buildRetainedAgentsSyncSignature', () => { ) const done = buildRetainedAgentsSyncSignature( makeSyncInputs({ - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [PANE_KEY]: makeEntry({ + paneKey: PANE_KEY, state: 'done', updatedAt: 3_000, stateStartedAt: 3_000, @@ -136,8 +141,8 @@ describe('buildRetainedAgentsSyncSignature', () => { it('tracks same-state done updates so retention keeps the final snapshot', () => { const done = buildRetainedAgentsSyncSignature( makeSyncInputs({ - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [PANE_KEY]: makeEntry({ + paneKey: PANE_KEY, state: 'done', updatedAt: 3_000, stateStartedAt: 3_000 @@ -146,8 +151,8 @@ describe('buildRetainedAgentsSyncSignature', () => { ) const updatedDone = buildRetainedAgentsSyncSignature( makeSyncInputs({ - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [PANE_KEY]: makeEntry({ + paneKey: PANE_KEY, state: 'done', updatedAt: 4_000, stateStartedAt: 3_000 @@ -175,14 +180,14 @@ describe('buildRetainedAgentsSyncSnapshot', () => { [archivedWorktree.id]: [archivedTab] }, agentStatusByPaneKey: { - 'tab-active:1': makeEntry({ - paneKey: 'tab-active:1', + [ACTIVE_PANE_KEY]: makeEntry({ + paneKey: ACTIVE_PANE_KEY, state: 'working', updatedAt: 10_000, stateStartedAt: 10_000 }), - 'tab-archived:1': makeEntry({ - paneKey: 'tab-archived:1', + [ARCHIVED_PANE_KEY]: makeEntry({ + paneKey: ARCHIVED_PANE_KEY, state: 'done', updatedAt: 20_000, stateStartedAt: 20_000 @@ -192,7 +197,7 @@ describe('buildRetainedAgentsSyncSnapshot', () => { }) expect([...snapshot.existingWorktreeIds]).toEqual(['wt-active']) - expect(snapshot.currentAgents.get('tab-active:1')?.row.state).toBe('idle') - expect(snapshot.currentAgents.get('tab-archived:1')).toBeUndefined() + expect(snapshot.currentAgents.get(ACTIVE_PANE_KEY)?.row.state).toBe('idle') + expect(snapshot.currentAgents.get(ARCHIVED_PANE_KEY)).toBeUndefined() }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 98e9053fe..71eaea8e1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -462,7 +462,9 @@ function SourceControlInner(): React.JSX.Element { const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD' const hostedReviewCacheKey = activeRepo && branchName ? getHostedReviewCacheKey(activeRepo.path, branchName, settings) : null - const hostedReviewEntry = hostedReviewCacheKey ? hostedReviewCache[hostedReviewCacheKey] : undefined + const hostedReviewEntry = hostedReviewCacheKey + ? hostedReviewCache[hostedReviewCacheKey] + : undefined const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey ? (hostedReviewEntry?.data ?? null) : null diff --git a/src/renderer/src/components/sidebar/CacheTimer.tsx b/src/renderer/src/components/sidebar/CacheTimer.tsx index 61f2f4476..2f4ee986e 100644 --- a/src/renderer/src/components/sidebar/CacheTimer.tsx +++ b/src/renderer/src/components/sidebar/CacheTimer.tsx @@ -32,7 +32,7 @@ export default function CacheTimer({ } let oldest: number | null = null for (const tab of tabs) { - // Why: cache timer keys are `${tabId}:${paneId}` composites, so we check + // Why: cache timer keys are `${tabId}:${leafId}` composites, so we check // all keys that belong to this tab's panes. for (const key of Object.keys(s.cacheTimerByKey)) { if (!key.startsWith(`${tab.id}:`)) { diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index bffed9d67..febf70e7a 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace' import { LinearIcon } from '@/components/icons/LinearIcon' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -77,6 +78,15 @@ const SidebarNav = React.memo(function SidebarNav() { count += 1 } } + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (!entry) { + continue + } + if ((s.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0) < entry.stateStartedAt) { + count += 1 + } + } return count }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 4d9c5f609..8499ea1de 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -7,6 +7,8 @@ import { useNow } from '@/components/dashboard/useNow' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row' type Props = { worktreeId: string @@ -51,7 +53,6 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }: BodyProps) { const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) - const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents) // Why: subscribe to the ack map reference (Object.is equality) and derive // per-agent unvisited flags locally. Keeps the inline list's bold/mute @@ -79,18 +80,21 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const handleActivateAgentTab = useCallback( (tabId: string, paneKey: string) => { - acknowledgeAgents([paneKey]) - const colon = paneKey.indexOf(':') - const tail = colon > 0 ? paneKey.slice(colon + 1) : '' - const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN - let paneId: number | null = null - if (Number.isFinite(parsed) && parsed > 0) { - paneId = parsed - } else { - // Why: paneKey for sidebar agent rows is always ${tabId}:${paneId} - // with a positive integer paneId; anything else (empty, zero, - // non-numeric) means upstream row construction drifted. + const parsed = parsePaneKey(paneKey) + if (!parsed) { + // Why: malformed or legacy numeric keys cannot be resolved safely after + // pane replay/remount, so drop the stale row instead of guessing. console.warn('[WorktreeCardAgents] malformed paneKey, skipping pane focus', paneKey) + dismissStaleAgentRowByKey(paneKey) + return + } + if (parsed.tabId !== tabId) { + console.warn('[WorktreeCardAgents] paneKey tabId mismatch, dismissing row', { + tabId, + paneKey + }) + dismissStaleAgentRowByKey(paneKey) + return } // Why: route through activateAndRevealWorktree so cross-repo clicks also // set activeRepoId, record a nav-history entry, clear sidebar filters, @@ -102,10 +106,12 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ activateAndRevealWorktree(worktreeId) const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] if (tabs.some((t) => t.id === tabId)) { - activateTabAndFocusPane(tabId, paneId) + activateTabAndFocusPane(tabId, parsed.leafId, { ackPaneKeyOnSuccess: paneKey }) + } else { + dismissStaleAgentRowByKey(paneKey) } }, - [worktreeId, acknowledgeAgents] + [worktreeId] ) // Why: own one 30s tick per non-empty inline list. Cards with zero agents diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 5cdfb2b7b..be729c1a1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -859,7 +859,9 @@ const WorktreeList = React.memo(function WorktreeList() { state.agentStatusByPaneKey, state.runtimePaneTitlesByTabId, state.ptyIdsByTabId, - now + now, + state.migrationUnsupportedByPtyId, + state.terminalLayoutsByTabId ) : new Map() lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 05120eae9..c4fbd18ce 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -12,7 +12,7 @@ import { resolveAttention, type PaneInput } from './smart-attention' -import type { TerminalTab, Worktree } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types' function hookPane(entry: AgentStatusEntry): PaneInput { return { kind: 'hook', entry } @@ -23,6 +23,31 @@ function hookPanes(entries: AgentStatusEntry[]): PaneInput[] { } const NOW = new Date('2026-03-27T12:00:00.000Z').getTime() +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function paneKey(tabId: string, leafId: string): string { + return `${tabId}:${leafId}` +} + +function splitLayout( + tabId: string, + firstLeafId = LEAF_1, + secondLeafId = LEAF_2 +): Record { + return { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: firstLeafId }, + second: { type: 'leaf', leafId: secondLeafId } + }, + activeLeafId: firstLeafId, + expandedLeafId: null + } + } +} function makeEntry(overrides: Partial & { paneKey: string }): AgentStatusEntry { return { @@ -382,14 +407,14 @@ describe('buildAttentionByWorktree', () => { const w = makeWorktree('wt-1') const tab = makeTab('tab-1', w.id) const entries: Record = { - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [paneKey(tab.id, LEAF_1)]: makeEntry({ + paneKey: paneKey(tab.id, LEAF_1), state: 'working', stateStartedAt: NOW - 10_000, updatedAt: NOW - 1_000 }), - 'tab-1:2': makeEntry({ - paneKey: 'tab-1:2', + [paneKey(tab.id, LEAF_2)]: makeEntry({ + paneKey: paneKey(tab.id, LEAF_2), state: 'blocked', stateStartedAt: NOW - 5_000, updatedAt: NOW - 1_000 @@ -460,8 +485,8 @@ describe('buildAttentionByWorktree', () => { const w = makeWorktree('wt-1') const tab = makeTab('tab-1', w.id) const entries: Record = { - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [paneKey(tab.id, LEAF_1)]: makeEntry({ + paneKey: paneKey(tab.id, LEAF_1), state: 'done', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 @@ -474,7 +499,9 @@ describe('buildAttentionByWorktree', () => { // Same paneId 1 — must NOT double-promote into Class 3. { [tab.id]: { 1: '⠋ Claude' } }, ptyMap([tab.id]), - NOW + NOW, + undefined, + splitLayout(tab.id) ) expect(map.get(w.id)).toEqual({ cls: 2, attentionTimestamp: NOW - 30_000 }) }) @@ -483,8 +510,8 @@ describe('buildAttentionByWorktree', () => { const w = makeWorktree('wt-1') const tab = makeTab('tab-1', w.id) const entries: Record = { - 'tab-1:1': makeEntry({ - paneKey: 'tab-1:1', + [paneKey(tab.id, LEAF_1)]: makeEntry({ + paneKey: paneKey(tab.id, LEAF_1), state: 'done', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 @@ -497,7 +524,9 @@ describe('buildAttentionByWorktree', () => { // Pane 2 has no hook — title fallback fires for it. { [tab.id]: { 1: 'something', 2: '✋ Gemini CLI' } }, ptyMap([tab.id]), - NOW + NOW, + undefined, + splitLayout(tab.id) ) expect(map.get(w.id)).toEqual({ cls: 1, diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 516ced3a1..7a1ba82e4 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,12 +1,21 @@ import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { AgentStatus } from '../../../../shared/agent-detection' -import type { TerminalTab, Worktree } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab, + Worktree +} from '../../../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStateHistoryEntry, - type AgentStatusEntry + type AgentStatusEntry, + type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' +import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id' +import { FIRST_PANE_ID } from '../../../../shared/pane-key' /** * Ordinal class for the "Smart" sort. Lower number = more attention-demanding. @@ -191,43 +200,90 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten * resolution pay O(T) lookups instead of scanning the full map. */ export function buildExplicitEntriesByTabId( - agentStatusByPaneKey: Record | undefined + agentStatusByPaneKey: Record | undefined, + migrationUnsupportedByPtyId?: Record ): Map { const byTab = new Map() - if (!agentStatusByPaneKey) { + const entries = [ + ...Object.values(agentStatusByPaneKey ?? {}), + ...Object.values(migrationUnsupportedByPtyId ?? {}).flatMap((entry) => { + const agentEntry = migrationUnsupportedToAgentStatusEntry(entry) + return agentEntry ? [agentEntry] : [] + }) + ] + if (entries.length === 0) { return byTab } - for (const entry of Object.values(agentStatusByPaneKey)) { - const colon = entry.paneKey.indexOf(':') - // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no - // colon or leading colon) rather than bucketing them under an empty tabId. - if (colon <= 0) { + for (const entry of entries) { + const parsed = parsePaneKey(entry.paneKey) + // Why: paneKey must be `${tabId}:${leafUuid}`. Skip malformed or legacy + // numeric entries rather than bucketing unroutable rows under a tab. + if (!parsed) { continue } - const tabId = entry.paneKey.slice(0, colon) - const bucket = byTab.get(tabId) + const bucket = byTab.get(parsed.tabId) if (bucket) { bucket.push(entry) } else { - byTab.set(tabId, [entry]) + byTab.set(parsed.tabId, [entry]) } } return byTab } /** - * Extract the paneId from a `${tabId}:${paneId}` paneKey, returning null for - * malformed keys (no colon or non-numeric tail). Used for per-pane authority: - * we need to know which paneIds already have a fresh hook entry so we don't - * double-count them via the title fallback. + * Extract the stable leaf id from a `${tabId}:${leafId}` paneKey. Used for + * per-pane authority: we need to know which leaves already have a fresh hook + * entry so we don't double-count them via the title fallback. */ -function paneIdFromPaneKey(paneKey: string): number | null { - const colon = paneKey.indexOf(':') - if (colon <= 0) { +function leafIdFromPaneKey(paneKey: string): string | null { + return parsePaneKey(paneKey)?.leafId ?? null +} + +function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { + return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) +} + +function collectReplayCreatedPaneLeafIds( + node: Extract, + leafIdsInReplayCreationOrder: string[] +): void { + leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second)) + + if (node.first.type === 'split') { + collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder) + } + if (node.second.type === 'split') { + collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder) + } +} + +function collectLeafIdsInReplayCreationOrder( + node: TerminalPaneLayoutNode | null | undefined +): string[] { + if (!node) { + return [] + } + const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)] + if (node.type === 'split') { + collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder) + } + return leafIdsInReplayCreationOrder +} + +function resolveRuntimePaneTitleLeafId( + tabLayout: TerminalLayoutSnapshot | undefined, + runtimePaneId: string +): string | null { + if (isTerminalLeafId(runtimePaneId)) { + return runtimePaneId + } + const numericPaneId = Number(runtimePaneId) + if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) { return null } - const id = Number(paneKey.slice(colon + 1)) - return Number.isFinite(id) ? id : null + const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.root) + return leafIds[numericPaneId - FIRST_PANE_ID] ?? null } /** @@ -248,9 +304,11 @@ export function buildAttentionByWorktree( agentStatusByPaneKey: Record | undefined, runtimePaneTitlesByTabId: Record>, ptyIdsByTabId: Record, - now: number + now: number, + migrationUnsupportedByPtyId?: Record, + terminalLayoutsByTabId?: Record ): Map { - const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey) + const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey, migrationUnsupportedByPtyId) const result = new Map() for (const worktree of worktrees) { @@ -262,9 +320,9 @@ export function buildAttentionByWorktree( const panes: PaneInput[] = [] for (const tab of tabs) { const hookEntries = byTab.get(tab.id) - // Why: paneIds covered by a hook entry skip the title fallback so we + // Why: leaf ids covered by a hook entry skip the title fallback so we // don't double-count them. Hook authority is per-pane. - const hookPaneIds = new Set() + const hookLeafIds = new Set() if (hookEntries) { for (const entry of hookEntries) { panes.push({ kind: 'hook', entry }) @@ -275,9 +333,9 @@ export function buildAttentionByWorktree( if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { continue } - const paneId = paneIdFromPaneKey(entry.paneKey) - if (paneId !== null) { - hookPaneIds.add(paneId) + const leafId = leafIdFromPaneKey(entry.paneKey) + if (leafId !== null) { + hookLeafIds.add(leafId) } } } @@ -293,9 +351,10 @@ export function buildAttentionByWorktree( if (paneTitles && Object.keys(paneTitles).length > 0) { // Why: split-pane tabs can host multiple agents; each pane reports // its own title. Mirrors the precedence used by getWorkingAgentsPerWorktree. - for (const [paneIdStr, title] of Object.entries(paneTitles)) { - const paneId = Number(paneIdStr) - if (hookPaneIds.has(paneId)) { + const tabLayout = terminalLayoutsByTabId?.[tab.id] + for (const [runtimePaneId, title] of Object.entries(paneTitles)) { + const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) + if (leafId !== null && hookLeafIds.has(leafId)) { continue } panes.push({ @@ -304,7 +363,7 @@ export function buildAttentionByWorktree( worktreeLastActivityAt: worktree.lastActivityAt }) } - } else if (hookPaneIds.size === 0) { + } else if (hookLeafIds.size === 0) { // Why: tabs we have not mounted yet (restored-but-unvisited) only // expose the legacy tab title. Fall back to it only when no pane-level // titles or hook entries exist for this tab. diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 6c4f9a014..a05536101 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -13,8 +13,15 @@ import { type AgentStateHistoryEntry, type AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' const NOW = new Date('2026-03-27T12:00:00.000Z').getTime() +const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' + +function paneKey(tabId: string, leaf: '1' | '2' = '1'): string { + return makePaneKey(tabId, leaf === '1' ? LEAF_ID_1 : LEAF_ID_2) +} const repoMap = new Map([ [ @@ -126,15 +133,15 @@ describe('smart sort — class invariants', () => { [done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })] } const entries = { - 'tab-blocked:1': makeEntry({ - paneKey: 'tab-blocked:1', + [paneKey('tab-blocked', '1')]: makeEntry({ + paneKey: paneKey('tab-blocked', '1'), state: 'blocked', // older than the done timestamp stateStartedAt: NOW - 5 * 60_000, updatedAt: NOW - 1_000 }), - 'tab-done:1': makeEntry({ - paneKey: 'tab-done:1', + [paneKey('tab-done', '1')]: makeEntry({ + paneKey: paneKey('tab-done', '1'), state: 'done', // newer stateStartedAt: NOW - 10_000, @@ -153,14 +160,14 @@ describe('smart sort — class invariants', () => { [working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })] } const entries = { - 'tab-done:1': makeEntry({ - paneKey: 'tab-done:1', + [paneKey('tab-done', '1')]: makeEntry({ + paneKey: paneKey('tab-done', '1'), state: 'done', stateStartedAt: NOW - 10 * 60_000, updatedAt: NOW - 1_000 }), - 'tab-working:1': makeEntry({ - paneKey: 'tab-working:1', + [paneKey('tab-working', '1')]: makeEntry({ + paneKey: paneKey('tab-working', '1'), state: 'working', // newer than the done — must still lose because class wins stateStartedAt: NOW - 1_000, @@ -185,8 +192,8 @@ describe('smart sort — class invariants', () => { [idle.id]: [makeTab({ id: 'tab-idle', worktreeId: idle.id })] } const entries = { - 'tab-working:1': makeEntry({ - paneKey: 'tab-working:1', + [paneKey('tab-working', '1')]: makeEntry({ + paneKey: paneKey('tab-working', '1'), state: 'working', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000 @@ -206,14 +213,14 @@ describe('smart sort — within-class recency', () => { [newer.id]: [makeTab({ id: 'tab-newer', worktreeId: newer.id })] } const entries = { - 'tab-older:1': makeEntry({ - paneKey: 'tab-older:1', + [paneKey('tab-older', '1')]: makeEntry({ + paneKey: paneKey('tab-older', '1'), state: 'blocked', stateStartedAt: NOW - 5 * 60_000, updatedAt: NOW - 1_000 }), - 'tab-newer:1': makeEntry({ - paneKey: 'tab-newer:1', + [paneKey('tab-newer', '1')]: makeEntry({ + paneKey: paneKey('tab-newer', '1'), state: 'blocked', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 @@ -231,16 +238,16 @@ describe('smart sort — within-class recency', () => { [fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })] } const entries = { - 'tab-with:1': makeEntry({ - paneKey: 'tab-with:1', + [paneKey('tab-with', '1')]: makeEntry({ + paneKey: paneKey('tab-with', '1'), state: 'working', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000, // Prior done from earlier in the session bumps within-class recency. stateHistory: [makeHistory('done', NOW - 5_000)] }), - 'tab-fresh:1': makeEntry({ - paneKey: 'tab-fresh:1', + [paneKey('tab-fresh', '1')]: makeEntry({ + paneKey: paneKey('tab-fresh', '1'), state: 'working', // Even newer current stateStartedAt — but with no history, falls back // to this timestamp (older than the prior done above). @@ -263,15 +270,15 @@ describe('smart sort — within-class recency', () => { [fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })] } const entries = { - 'tab-i:1': makeEntry({ - paneKey: 'tab-i:1', + [paneKey('tab-i', '1')]: makeEntry({ + paneKey: paneKey('tab-i', '1'), state: 'working', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000, stateHistory: [makeHistory('done', NOW - 5_000, true)] }), - 'tab-f:1': makeEntry({ - paneKey: 'tab-f:1', + [paneKey('tab-f', '1')]: makeEntry({ + paneKey: paneKey('tab-f', '1'), state: 'working', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 @@ -297,15 +304,15 @@ describe('smart sort — interrupted and stale handling', () => { [realDone.id]: [makeTab({ id: 'tab-d', worktreeId: realDone.id })] } const entries = { - 'tab-i:1': makeEntry({ - paneKey: 'tab-i:1', + [paneKey('tab-i', '1')]: makeEntry({ + paneKey: paneKey('tab-i', '1'), state: 'done', interrupted: true, stateStartedAt: NOW - 1_000, updatedAt: NOW - 500 }), - 'tab-d:1': makeEntry({ - paneKey: 'tab-d:1', + [paneKey('tab-d', '1')]: makeEntry({ + paneKey: paneKey('tab-d', '1'), state: 'done', stateStartedAt: NOW - 5 * 60_000, updatedAt: NOW - 1_000 @@ -327,14 +334,14 @@ describe('smart sort — interrupted and stale handling', () => { [fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })] } const entries = { - 'tab-s:1': makeEntry({ - paneKey: 'tab-s:1', + [paneKey('tab-s', '1')]: makeEntry({ + paneKey: paneKey('tab-s', '1'), state: 'blocked', stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000, updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000 }), - 'tab-f:1': makeEntry({ - paneKey: 'tab-f:1', + [paneKey('tab-f', '1')]: makeEntry({ + paneKey: paneKey('tab-f', '1'), state: 'done', stateStartedAt: NOW - 5 * 60_000, updatedAt: NOW - 1_000 @@ -417,20 +424,20 @@ describe('smart sort — multi-pane resolution', () => { [otherDone.id]: [makeTab({ id: 'tab-other', worktreeId: otherDone.id })] } const entries = { - 'tab-split:1': makeEntry({ - paneKey: 'tab-split:1', + [paneKey('tab-split', '1')]: makeEntry({ + paneKey: paneKey('tab-split', '1'), state: 'working', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000 }), - 'tab-split:2': makeEntry({ - paneKey: 'tab-split:2', + [paneKey('tab-split', '2')]: makeEntry({ + paneKey: paneKey('tab-split', '2'), state: 'blocked', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 }), - 'tab-other:1': makeEntry({ - paneKey: 'tab-other:1', + [paneKey('tab-other', '1')]: makeEntry({ + paneKey: paneKey('tab-other', '1'), state: 'done', stateStartedAt: NOW - 5_000, updatedAt: NOW - 1_000 @@ -473,14 +480,14 @@ describe('sortWorktreesSmart — cold start fallback', () => { [done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })] } const entries = { - 'tab-blocked:1': makeEntry({ - paneKey: 'tab-blocked:1', + [paneKey('tab-blocked', '1')]: makeEntry({ + paneKey: paneKey('tab-blocked', '1'), state: 'blocked', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000 }), - 'tab-done:1': makeEntry({ - paneKey: 'tab-done:1', + [paneKey('tab-done', '1')]: makeEntry({ + paneKey: paneKey('tab-done', '1'), state: 'done', stateStartedAt: NOW - 30_000, updatedAt: NOW - 1_000 @@ -511,14 +518,14 @@ describe('sortWorktreesSmart — palette caller regression', () => { [working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })] } const agentStatusByPaneKey: Record = { - 'tab-blocked:1': makeEntry({ - paneKey: 'tab-blocked:1', + [paneKey('tab-blocked', '1')]: makeEntry({ + paneKey: paneKey('tab-blocked', '1'), state: 'blocked', stateStartedAt: NOW - 60_000, updatedAt: NOW - 1_000 }), - 'tab-working:1': makeEntry({ - paneKey: 'tab-working:1', + [paneKey('tab-working', '1')]: makeEntry({ + paneKey: paneKey('tab-working', '1'), state: 'working', // newer than the blocked one — would win on recency alone stateStartedAt: NOW - 1_000, diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 3d539c2f3..96872ba9f 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -1,5 +1,8 @@ -import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Worktree, Repo, TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { + AgentStatusEntry, + MigrationUnsupportedPtyEntry +} from '../../../../shared/agent-status-types' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention' @@ -121,7 +124,9 @@ export function sortWorktreesSmart( repoMap: Map, agentStatusByPaneKey: Record, runtimePaneTitlesByTabId: Record>, - ptyIdsByTabId: Record + ptyIdsByTabId: Record, + migrationUnsupportedByPtyId?: Record, + terminalLayoutsByTabId?: Record ): Worktree[] { // Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for // liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading @@ -145,7 +150,9 @@ export function sortWorktreesSmart( agentStatusByPaneKey, runtimePaneTitlesByTabId, ptyIdsByTabId, - now + now, + migrationUnsupportedByPtyId, + terminalLayoutsByTabId ) return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, now, attentionByWorktree)) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 47e5c0ec8..af547f8e8 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -2,8 +2,10 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers' import { selectLivePtyIdsForWorktree, @@ -31,12 +33,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const tabIds = new Set(wtTabs.map((tab) => tab.id)) const now = Date.now() for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - const sepIdx = paneKey.indexOf(':') - if (sepIdx <= 0) { - continue - } - const tabId = paneKey.slice(0, sepIdx) - if (!tabIds.has(tabId)) { + const parsed = parsePaneKey(paneKey) + if (!parsed || !tabIds.has(parsed.tabId)) { continue } if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { @@ -48,6 +46,16 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { live = true } } + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (!entry) { + continue + } + const parsed = parsePaneKey(entry.paneKey) + if (parsed && tabIds.has(parsed.tabId)) { + perm = true + } + } } let retained = false diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 1b010a43d..986e96d1f 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -6,6 +6,11 @@ import { import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { buildWorktreeAgentRows } from './useWorktreeAgentRows' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111') +const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') +const PANE_KEY_2 = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333') function makeTab(id: string): TerminalTab { return { @@ -57,20 +62,20 @@ describe('buildWorktreeAgentRows', () => { // Why: useWorktreeAgentRows filters retained snapshots by worktreeId, not // current tab membership. This is the sidebar behavior that sleep cleanup // must counter by dropping worktree-scoped retained rows. - retained: [makeRetained('tab-orphan:0', 'wt-1', 1000)], + retained: [makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000)], now: 2000 }) - expect(rows.map((row) => row.paneKey)).toEqual(['tab-orphan:0']) + expect(rows.map((row) => row.paneKey)).toEqual([ORPHAN_PANE_KEY]) expect(rows[0].state).toBe('done') }) it('prefers a live row over a retained snapshot with the same paneKey', () => { - const liveEntry = makeEntry('tab-1:0', 2000) + const liveEntry = makeEntry(PANE_KEY_1, 2000) const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1')], entries: [liveEntry], - retained: [makeRetained('tab-1:0', 'wt-1', 1000)], + retained: [makeRetained(PANE_KEY_1, 'wt-1', 1000)], now: 3000 }) @@ -90,15 +95,15 @@ describe('buildWorktreeAgentRows', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1'), makeTab('tab-2')], entries: [ - makeEntry('tab-1:0', staleAt, { state: 'working', updatedAt: staleAt }), - makeEntry('tab-2:0', freshDoneAt, { state: 'done', updatedAt: freshDoneAt }) + makeEntry(PANE_KEY_1, staleAt, { state: 'working', updatedAt: staleAt }), + makeEntry(PANE_KEY_2, freshDoneAt, { state: 'done', updatedAt: freshDoneAt }) ], retained: [], now }) - const working = rows.find((r) => r.paneKey === 'tab-1:0') - const done = rows.find((r) => r.paneKey === 'tab-2:0') + const working = rows.find((r) => r.paneKey === PANE_KEY_1) + const done = rows.find((r) => r.paneKey === PANE_KEY_2) expect(working?.state).toBe('idle') expect(done?.state).toBe('done') }) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index c0330715a..20d13af57 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -9,6 +9,8 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' // Why: stable empty-array references so narrow selectors return the same // reference when there's nothing for this worktree. Without stable empties, @@ -29,16 +31,15 @@ export function buildWorktreeAgentRows(args: { const entriesByTabId = new Map() for (const entry of args.entries) { - const colonIndex = entry.paneKey.indexOf(':') - if (colonIndex === -1) { + const parsed = parsePaneKey(entry.paneKey) + if (!parsed) { continue } - const tabId = entry.paneKey.slice(0, colonIndex) - const bucket = entriesByTabId.get(tabId) + const bucket = entriesByTabId.get(parsed.tabId) if (bucket) { bucket.push(entry) } else { - entriesByTabId.set(tabId, [entry]) + entriesByTabId.set(parsed.tabId, [entry]) } } @@ -105,12 +106,22 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { const tabIds = new Set(wtTabs.map((t) => t.id)) const out: AgentStatusEntry[] = [] for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - const sepIdx = paneKey.indexOf(':') - if (sepIdx <= 0) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { continue } - const tabId = paneKey.slice(0, sepIdx) - if (!tabIds.has(tabId)) { + if (!tabIds.has(parsed.tabId)) { + continue + } + out.push(entry) + } + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + const entry = migrationUnsupportedToAgentStatusEntry(unsupported) + if (!entry) { + continue + } + const parsed = parsePaneKey(entry.paneKey) + if (!parsed || !tabIds.has(parsed.tabId)) { continue } out.push(entry) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 2f6a632b8..d901b2c35 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -186,7 +186,9 @@ export function getVisibleWorktreeIds(): string[] { repoMap, state.agentStatusByPaneKey, state.runtimePaneTitlesByTabId, - state.ptyIdsByTabId + state.ptyIdsByTabId, + state.migrationUnsupportedByPtyId, + state.terminalLayoutsByTabId ).map((w) => w.id) } else { // Why empty map: non-smart branches don't read attentionByWorktree, but diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index bf6ae9c79..213a0de60 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -38,6 +38,7 @@ import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID, @@ -908,17 +909,10 @@ export function ResourceUsageStatusSegment({ } } setActiveView('terminal') - // Why: snapshot-derived rows carry a `${tabId}:${paneId}` paneKey from - // the main-process pty registry — parse the paneId tail so split-tab - // clicks land focus on the *clicked* pane rather than whichever pane - // was last active. Daemon-only rows have paneKey=null and degrade to - // tab-only activation. - const colon = paneKey ? paneKey.indexOf(':') : -1 - const tail = colon > 0 && paneKey ? paneKey.slice(colon + 1) : '' - const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN - const paneId = - Number.isFinite(parsed) && parsed > 0 && paneKey?.slice(0, colon) === tabId ? parsed : null - activateTabAndFocusPane(tabId, paneId) + // Why: paneKey suffixes are stable UUID leaf ids after replay/reload. + // Legacy numeric keys degrade to tab-only activation instead of guessing. + const parsed = paneKey ? parsePaneKey(paneKey) : null + activateTabAndFocusPane(tabId, parsed?.tabId === tabId ? parsed.leafId : null) }, [tabsByWorktree, setActiveView] ) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 8bf4c3e9e..bccc250fe 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -28,8 +28,9 @@ import type { TerminalTab, WorktreeMemory } from '../../../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' import { parsePtySessionId } from '../../../../shared/pty-session-id-format' +import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' // ─── View-model types (renderer-local) ────────────────────────────── @@ -131,19 +132,12 @@ function shortCwd(cwd: string): string { return parts.length > 2 ? parts.slice(-2).join(sep) : cwd } -function parsePaneKey(paneKey: string | null): { tabId: string; paneRuntimeId: number } | null { +function parsePaneKey(paneKey: string | null): { tabId: string; leafId: string } | null { if (!paneKey) { return null } - const sepIdx = paneKey.indexOf(':') - if (sepIdx <= 0) { - return null - } - const paneRuntimeId = Number(paneKey.slice(sepIdx + 1)) - if (!Number.isFinite(paneRuntimeId)) { - return null - } - return { tabId: paneKey.slice(0, sepIdx), paneRuntimeId } + const parsed = parseStablePaneKey(paneKey) + return parsed ? { tabId: parsed.tabId, leafId: parsed.leafId } : null } function resolveSnapshotSessionLabel( @@ -161,10 +155,6 @@ function resolveSnapshotSessionLabel( if (custom) { return custom } - const runtime = ctx.runtimePaneTitlesByTabId[parsed.tabId]?.[parsed.paneRuntimeId]?.trim() - if (runtime) { - return runtime - } return tab.defaultTitle?.trim() || tab.title?.trim() || `Terminal ${tabIndex + 1}` } } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 0dd84c776..f891f762d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,8 @@ import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers' import { getConnectionId } from '@/lib/connection-context' import { resolveTerminalDropTargetShell } from './terminal-drop-handler' -import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization' +import { EMPTY_LAYOUT, serializeTerminalLayout } from './layout-serialization' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, createExpandCollapseActions, @@ -38,6 +39,7 @@ import { connectPanePty } from './pty-connection' import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers' import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides' import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state' +import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' @@ -64,7 +66,7 @@ type TerminalPaneProps = { // override (separate snapshot ref) — does NOT touch expandedPaneId state // or persist to the layout snapshot, so returning to the workspace shows // the original split layout unchanged. - isolatedPaneId?: number | null + isolatedPaneKey?: string | null onPtyExit: (ptyId: string) => void onCloseTab: () => void } @@ -75,7 +77,7 @@ export default function TerminalPane({ cwd, isActive, isVisible = true, - isolatedPaneId = null, + isolatedPaneKey = null, onPtyExit, onCloseTab }: TerminalPaneProps): React.JSX.Element { @@ -319,10 +321,16 @@ export default function TerminalPane({ return } const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null - const layout = serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current) + const leafIdByPaneId = manager.getLeafIdMap() + const layout = serializeTerminalLayout( + container, + activePaneId, + expandedPaneIdRef.current, + leafIdByPaneId + ) const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] const currentPanes = manager.getPanes() - const currentLeafIds = new Set(currentPanes.map((p) => paneLeafId(p.id))) + const currentLeafIds = new Set(currentPanes.map((p) => p.leafId)) // Preserve existing buffersByLeafId so layout-only persists (resize, split, // reorder) don't clobber previously captured scrollback. Drop entries for // leaves that no longer exist. @@ -341,10 +349,11 @@ export default function TerminalPane({ // successive remount (tab moved again before the first rAF) would lose // the mappings and force fresh PTY spawns. const livePtyEntries = currentPanes - .map( - (p) => [paneLeafId(p.id), paneTransportsRef.current.get(p.id)?.getPtyId() ?? null] as const + .map((p) => [p.leafId, paneTransportsRef.current.get(p.id)?.getPtyId() ?? null] as const) + .filter( + (entry): entry is readonly [(typeof currentPanes)[number]['leafId'], string] => + entry[1] !== null ) - .filter((entry): entry is readonly [string, string] => entry[1] !== null) const mergedPtyIds = mergeCapturedLeafState({ prior: existing?.ptyIdsByLeafId, fresh: Object.fromEntries(livePtyEntries), @@ -359,7 +368,7 @@ export default function TerminalPane({ const titles = paneTitlesRef.current const titleEntries = currentPanes .filter((p) => titles[p.id]) - .map((p) => [paneLeafId(p.id), titles[p.id]] as const) + .map((p) => [p.leafId, titles[p.id]] as const) if (titleEntries.length > 0) { layout.titlesByLeafId = Object.fromEntries(titleEntries) } @@ -372,7 +381,10 @@ export default function TerminalPane({ const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout const existingBindings = existingLayout.ptyIdsByLeafId ?? {} - const leafId = paneLeafId(paneId) + const leafId = managerRef.current?.getLeafId(paneId) + if (!leafId) { + return + } if (ptyId) { setTabLayout(tabId, { @@ -429,13 +441,12 @@ export default function TerminalPane({ // so the sidebar doesn't show a stale countdown for a pane that no // longer exists. The closeTab path handles bulk cleanup, but closing // a single split pane doesn't go through closeTab. - useAppStore.getState().setCacheTimerStartedAt(`${tabId}:${paneId}`, null) + const leafId = manager.getLeafId(paneId) + if (leafId) { + useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) + useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId)) + } syncPanePtyLayoutBinding(paneId, null) - // Why: Cmd+W on a split pane is user-initiated teardown — drop (not - // remove) so any retained `done` snapshot for this pane is also cleared - // and a same-frame live→gone transition cannot re-snapshot it via the - // retention sync. - useAppStore.getState().dropAgentStatus(`${tabId}:${paneId}`) manager.closePane(paneId) } }, @@ -559,20 +570,33 @@ export default function TerminalPane({ safeFit(pane) } }) - if (isolatedPaneId === null) { + if (isolatedPaneKey === null) { restoreExpandedLayoutFrom(snapshots) const frame = scheduleRefit() return () => { cancelAnimationFrame(frame) } } - const applied = applyExpandedLayoutTo(isolatedPaneId, { - managerRef, - containerRef, - expandedStyleSnapshotRef: activityIsolationSnapshotRef - }) + const manager = managerRef.current + const resolution = resolvePaneKeyForManager(tabId, isolatedPaneKey, manager) + const resolvedPaneId = resolution.status === 'resolved' ? resolution.numericPaneId : null + const applied = + resolvedPaneId !== null && + ((manager?.getPanes().length ?? 0) <= 1 || + applyExpandedLayoutTo(resolvedPaneId, { + managerRef, + containerRef, + expandedStyleSnapshotRef: activityIsolationSnapshotRef + })) if (!applied) { restoreExpandedLayoutFrom(snapshots) + const root = containerRef.current?.firstElementChild + if (root instanceof HTMLElement) { + // Why: Activity requested an exact pane. If it cannot be resolved, fail + // closed instead of showing the whole split terminal as a fallback. + snapshots.set(root, { display: root.style.display, flex: root.style.flex }) + root.style.display = 'none' + } const frame = scheduleRefit() return () => { cancelAnimationFrame(frame) @@ -582,7 +606,7 @@ export default function TerminalPane({ return () => { cancelAnimationFrame(frame) } - }, [isolatedPaneId, paneCount]) + }, [isolatedPaneKey, paneCount, tabId]) // Why: belt-and-suspenders unmount cleanup. If the component unmounts // while isolation is active (e.g. tab closed mid-Activity-view), restore @@ -621,7 +645,7 @@ export default function TerminalPane({ syncPanePtyLayoutBinding(paneId, null) transport?.destroy?.() paneTransportsRef.current.delete(paneId) - setCacheTimerStartedAt(`${tabId}:${paneId}`, null) + setCacheTimerStartedAt(makePaneKey(tabId, pane.leafId), null) setTerminalError(null) const newPaneBinding = connectPanePty(pane, manager, { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index b734b8b7e..cf6151326 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -91,7 +91,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. isVisible={isVisible || activityTerminalPortal !== null} - isolatedPaneId={activityTerminalPortal?.paneId ?? null} + isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { return @@ -205,11 +205,10 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const assignment = assignments.get(terminalTab.id) const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) - const activityTerminalPortal = findActivityTerminalPortal( - activityTerminalPortals, + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { worktreeId, - terminalTab.id - ) + tabId: terminalTab.id + }) return ( | null + acknowledgeAgents: (paneKeys: string[]) => void + surfaceStaleAgentRow: (tabId: string, leafId: string) => void +} + +export function handleFocusTerminalPaneDetail( + detail: FocusTerminalPaneDetail | undefined, + { tabId, manager, acknowledgeAgents, surfaceStaleAgentRow }: FocusTerminalPaneEventDeps +): void { + if (!detail?.tabId || detail.tabId !== tabId) { + return + } + if (!manager || !detail.leafId) { + return + } + const resolution = resolveLeafIdForManager( + tabId, + detail.leafId, + manager, + detail.ackPaneKeyOnSuccess ?? null + ) + if (resolution.status !== 'resolved') { + // Why: stale pane keys must fail closed instead of focusing a sibling pane. + if (resolution.leafId) { + surfaceStaleAgentRow(tabId, resolution.leafId) + } + return + } + manager.setActivePane(resolution.numericPaneId, { focus: true }) + if (detail.ackPaneKeyOnSuccess) { + acknowledgeAgents([detail.ackPaneKeyOnSuccess]) + } +} diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index f4fe088ad..d533f81e0 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: this test keeps split layout replay fixtures together so + * stable leaf-id migration regressions are visible in one focused suite. */ import { describe, expect, it, beforeAll } from 'vitest' import type { TerminalPaneLayoutNode } from '../../../../shared/types' @@ -33,7 +35,6 @@ beforeAll(() => { }) import { - paneLeafId, buildFontFamily, serializePaneTree, serializeTerminalLayout, @@ -55,22 +56,10 @@ function mockElement(opts: { return new MockHTMLElement(opts) as unknown as HTMLElement } -// --------------------------------------------------------------------------- -// paneLeafId -// --------------------------------------------------------------------------- -describe('paneLeafId', () => { - it('returns "pane:0" for paneId 0', () => { - expect(paneLeafId(0)).toBe('pane:0') - }) - - it('returns "pane:1" for paneId 1', () => { - expect(paneLeafId(1)).toBe('pane:1') - }) - - it('returns "pane:42" for paneId 42', () => { - expect(paneLeafId(42)).toBe('pane:42') - }) -}) +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' +const LEAF_4 = '44444444-4444-4444-8444-444444444444' // --------------------------------------------------------------------------- // buildFontFamily @@ -132,36 +121,53 @@ describe('serializePaneTree', () => { }) it('returns a leaf node for a single pane', () => { - const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1' } }) - expect(serializePaneTree(pane)).toEqual({ type: 'leaf', leafId: 'pane:1' }) + const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1', leafId: LEAF_1 } }) + expect(serializePaneTree(pane)).toEqual({ type: 'leaf', leafId: LEAF_1 }) }) - it('returns null for a pane with non-numeric paneId', () => { + it('returns null for a pane without a UUID leaf id', () => { const pane = mockElement({ classList: ['pane'], dataset: { paneId: 'abc' } }) expect(serializePaneTree(pane)).toBeNull() }) + it('returns null for a pane with a legacy leaf id', () => { + const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1', leafId: 'pane:1' } }) + expect(serializePaneTree(pane)).toBeNull() + }) + it('returns null for element that is neither pane nor pane-split', () => { const el = mockElement({ classList: ['random-class'] }) expect(serializePaneTree(el)).toBeNull() }) it('returns a vertical split node with two pane children', () => { - const first = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '1' } }) - const second = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '2' } }) + const first = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '1', leafId: LEAF_1 } + }) + const second = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '2', leafId: LEAF_2 } + }) const split = mockElement({ classList: ['pane-split'], children: [first, second] }) expect(serializePaneTree(split)).toEqual({ type: 'split', direction: 'vertical', - first: { type: 'leaf', leafId: 'pane:1' }, - second: { type: 'leaf', leafId: 'pane:2' } + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } }) }) it('returns horizontal direction when split has is-horizontal class', () => { - const first = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '3' } }) - const second = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '4' } }) + const first = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '3', leafId: LEAF_3 } + }) + const second = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '4', leafId: LEAF_4 } + }) const split = mockElement({ classList: ['pane-split', 'is-horizontal'], children: [first, second] @@ -170,20 +176,20 @@ describe('serializePaneTree', () => { expect(serializePaneTree(split)).toEqual({ type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'pane:3' }, - second: { type: 'leaf', leafId: 'pane:4' } + first: { type: 'leaf', leafId: LEAF_3 }, + second: { type: 'leaf', leafId: LEAF_4 } }) }) it('captures flex ratio when children have unequal flex', () => { const first = new MockHTMLElement({ classList: ['pane'], - dataset: { paneId: '1' }, + dataset: { paneId: '1', leafId: LEAF_1 }, style: { flex: '3' } }) const second = new MockHTMLElement({ classList: ['pane'], - dataset: { paneId: '2' }, + dataset: { paneId: '2', leafId: LEAF_2 }, style: { flex: '1' } }) const split = mockElement({ classList: ['pane-split'], children: [first, second] }) @@ -192,8 +198,8 @@ describe('serializePaneTree', () => { expect(result).toEqual({ type: 'split', direction: 'vertical', - first: { type: 'leaf', leafId: 'pane:1' }, - second: { type: 'leaf', leafId: 'pane:2' }, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 }, ratio: 0.75 }) }) @@ -201,12 +207,12 @@ describe('serializePaneTree', () => { it('omits ratio when flex values are equal (both 1)', () => { const first = new MockHTMLElement({ classList: ['pane'], - dataset: { paneId: '1' }, + dataset: { paneId: '1', leafId: LEAF_1 }, style: { flex: '1' } }) const second = new MockHTMLElement({ classList: ['pane'], - dataset: { paneId: '2' }, + dataset: { paneId: '2', leafId: LEAF_2 }, style: { flex: '1' } }) const split = mockElement({ classList: ['pane-split'], children: [first, second] }) @@ -216,9 +222,18 @@ describe('serializePaneTree', () => { }) it('handles nested splits recursively', () => { - const leaf1 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '1' } }) - const leaf2 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '2' } }) - const leaf3 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '3' } }) + const leaf1 = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '1', leafId: LEAF_1 } + }) + const leaf2 = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '2', leafId: LEAF_2 } + }) + const leaf3 = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '3', leafId: LEAF_3 } + }) const innerSplit = new MockHTMLElement({ classList: ['pane-split', 'is-horizontal'], @@ -232,12 +247,12 @@ describe('serializePaneTree', () => { expect(serializePaneTree(outerSplit)).toEqual({ type: 'split', direction: 'vertical', - first: { type: 'leaf', leafId: 'pane:1' }, + first: { type: 'leaf', leafId: LEAF_1 }, second: { type: 'split', direction: 'horizontal', - first: { type: 'leaf', leafId: 'pane:2' }, - second: { type: 'leaf', leafId: 'pane:3' } + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } } }) }) @@ -257,7 +272,51 @@ describe('serializeTerminalLayout', () => { const result = serializeTerminalLayout(root, 5, null) expect(result).toEqual({ root: null, - activeLeafId: 'pane:5', + activeLeafId: null, + expandedLeafId: null + }) + }) + + it('uses UUID leaf ids from the live pane map for active and expanded panes', () => { + const child = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '5', leafId: LEAF_1 } + }) + const root = mockElement({ firstElementChild: child }) as unknown as HTMLDivElement + const result = serializeTerminalLayout( + root, + 5, + 6, + new Map([ + [5, LEAF_1], + [6, LEAF_2] + ]) + ) + expect(result).toEqual({ + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_2 + }) + }) + + it('does not serialize legacy active pane ids when the live map is missing UUIDs', () => { + const child = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: '5', leafId: LEAF_1 } + }) + const root = mockElement({ firstElementChild: child }) as unknown as HTMLDivElement + const result = serializeTerminalLayout( + root, + 5, + 6, + new Map([ + [5, 'pane:5'], + [6, 'pane:6'] + ]) + ) + expect(result).toEqual({ + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: null, expandedLeafId: null }) }) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 6127da765..7441256fa 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -3,8 +3,16 @@ import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { getLeftmostLeafId, normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids' + +export { + collectLeafIdsInOrder, + collectLeafIdsInReplayCreationOrder, + normalizeTerminalLayoutSnapshot +} from './terminal-layout-leaf-ids' export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { root: null, @@ -49,54 +57,6 @@ export const POST_REPLAY_MODE_RESET = // as unbound key input). export const POST_REPLAY_FOCUS_REPORTING_RESET = '\x1b[?25h\x1b[?1004l' -export function paneLeafId(paneId: number): string { - return `pane:${paneId}` -} - -export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] { - if (!node) { - return [] - } - if (node.type === 'leaf') { - return [node.leafId] - } - return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)] -} - -function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { - return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) -} - -function collectReplayCreatedPaneLeafIds( - node: Extract, - leafIdsInReplayCreationOrder: string[] -): void { - // Why: replayTerminalLayout() creates one new pane per split and assigns it - // to the split's second subtree before recursing, so the new pane maps to - // the leftmost leaf reachable within that second subtree. - leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second)) - - if (node.first.type === 'split') { - collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder) - } - if (node.second.type === 'split') { - collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder) - } -} - -export function collectLeafIdsInReplayCreationOrder( - node: TerminalPaneLayoutNode | null | undefined -): string[] { - if (!node) { - return [] - } - const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)] - if (node.type === 'split') { - collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder) - } - return leafIdsInReplayCreationOrder -} - // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / @@ -156,11 +116,11 @@ export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutN } if (node.classList.contains('pane')) { - const paneId = Number(node.dataset.paneId ?? '') - if (!Number.isFinite(paneId)) { + const leafId = node.dataset.leafId + if (!leafId || !isTerminalLeafId(leafId)) { return null } - return { type: 'leaf', leafId: paneLeafId(paneId) } + return { type: 'leaf', leafId } } if (!node.classList.contains('pane-split')) { @@ -201,31 +161,21 @@ export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutN export function serializeTerminalLayout( root: HTMLDivElement | null, activePaneId: number | null, - expandedPaneId: number | null + expandedPaneId: number | null, + leafIdByPaneId?: ReadonlyMap ): TerminalLayoutSnapshot { const rootNode = serializePaneTree( root?.firstElementChild instanceof HTMLElement ? root.firstElementChild : null ) + const activeLeafId = activePaneId === null ? null : leafIdByPaneId?.get(activePaneId) + const expandedLeafId = expandedPaneId === null ? null : leafIdByPaneId?.get(expandedPaneId) return { root: rootNode, - activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId), - expandedLeafId: expandedPaneId === null ? null : paneLeafId(expandedPaneId) + activeLeafId: activeLeafId && isTerminalLeafId(activeLeafId) ? activeLeafId : null, + expandedLeafId: expandedLeafId && isTerminalLeafId(expandedLeafId) ? expandedLeafId : null } } -function collectLeafIds( - node: TerminalPaneLayoutNode, - paneByLeafId: Map, - paneId: number -): void { - if (node.type === 'leaf') { - paneByLeafId.set(node.leafId, paneId) - return - } - collectLeafIds(node.first, paneByLeafId, paneId) - collectLeafIds(node.second, paneByLeafId, paneId) -} - /** * Write saved scrollback buffers into the restored panes so the user sees * their previous terminal output after an app restart. If a buffer was @@ -289,9 +239,12 @@ export function replayTerminalLayout( ): Map { const paneByLeafId = new Map() - const initialPane = manager.createInitialPane({ focus: focusInitialPane }) + const normalized = normalizeTerminalLayoutSnapshot(snapshot) + snapshot = normalized.snapshot + const initialLeafId = snapshot.root ? getLeftmostLeafId(snapshot.root) : undefined + const initialPane = manager.createInitialPane({ focus: focusInitialPane, leafId: initialLeafId }) if (!snapshot?.root) { - paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id) + paneByLeafId.set(initialPane.leafId, initialPane.id) return paneByLeafId } @@ -302,10 +255,11 @@ export function replayTerminalLayout( } const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, { - ratio: node.ratio + ratio: node.ratio, + leafId: getLeftmostLeafId(node.second) }) if (!createdPane) { - collectLeafIds(node, paneByLeafId, paneId) + restoreNode(node.first, paneId) return } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ffc8cf5c7..b6752f858 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3,6 +3,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_FOCUS_REPORTING_RESET, POST_REPLAY_MODE_RESET } from './layout-serialization' import type * as UseNotificationDispatchModule from './use-notification-dispatch' +import { makePaneKey } from '../../../../shared/stable-pane-id' // Why: the fresh-spawn and reattach paths now chain pre-signal → spawn → // register/settle through multiple microtasks. Tests that previously flushed @@ -15,6 +16,12 @@ async function flushAsyncTicks(count = 6): Promise { } const toastInfo = vi.fn() +const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const +const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const + +function leafIdForPane(paneId: number): string { + return paneId === 2 ? LEAF_2 : LEAF_1 +} type StoreState = { tabsByWorktree: Record @@ -171,8 +178,11 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport } function createPane(paneId: number) { + const leafId = leafIdForPane(paneId) return { id: paneId, + leafId, + stablePaneId: leafId, terminal: { cols: 120, rows: 40, @@ -195,7 +205,12 @@ function createManager(paneCount = 1) { return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - getPanes: vi.fn(() => Array.from({ length: paneCount }, (_, index) => ({ id: index + 1 }))), + getPanes: vi.fn(() => + Array.from({ length: paneCount }, (_, index) => ({ + id: index + 1, + leafId: leafIdForPane(index + 1) + })) + ), closePane: vi.fn(), getActivePane: vi.fn<() => { id: number } | null>(() => null) } @@ -586,11 +601,12 @@ describe('connectPanePty', () => { return 'pty-local-1' }) transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) mockStoreState = { ...mockStoreState, agentStatusByPaneKey: { - 'tab-1:1': { - paneKey: 'tab-1:1', + [paneKey]: { + paneKey, state: 'done', prompt: 'hi', updatedAt: 1000, @@ -609,7 +625,7 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith('tab-1:1') + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) @@ -626,8 +642,8 @@ describe('connectPanePty', () => { const pane = createPane(2) const manager = createManager(2) const deps = createDeps({ - restoredLeafId: 'pane:2', - restoredPtyIdByLeafId: { 'pane:2': 'leaf-pty-2' } + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'leaf-pty-2' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -666,8 +682,8 @@ describe('connectPanePty', () => { const pane = createPane(2) const manager = createManager(2) const deps = createDeps({ - restoredLeafId: 'pane:2', - restoredPtyIdByLeafId: { 'pane:2': 'stale-pty' } + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'stale-pty' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -705,8 +721,8 @@ describe('connectPanePty', () => { const pane = createPane(2) const manager = createManager(2) const deps = createDeps({ - restoredLeafId: 'pane:2', - restoredPtyIdByLeafId: { 'pane:2': 'restored-session' } + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -744,8 +760,8 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - restoredLeafId: 'pane:1', - restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -795,8 +811,8 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - restoredLeafId: 'pane:1', - restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -830,8 +846,8 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - restoredLeafId: 'pane:1', - restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -1069,8 +1085,8 @@ describe('connectPanePty', () => { const remountPane = createPane(1) const remountManager = createManager(1) const remountDeps = createDeps({ - restoredLeafId: 'pane:1', - restoredPtyIdByLeafId: { 'pane:1': 'pty-restarted' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'pty-restarted' } }) connectPanePty(remountPane as never, remountManager as never, remountDeps as never) @@ -1239,8 +1255,8 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - restoredLeafId: 'leaf-1', - restoredPtyIdByLeafId: { 'leaf-1': 'leaf-session' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'leaf-session' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -1343,8 +1359,8 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - restoredLeafId: 'leaf-1', - restoredPtyIdByLeafId: { 'leaf-1': 'leaf-session' } + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'leaf-session' } }) connectPanePty(pane as never, manager as never, deps as never) @@ -1434,6 +1450,6 @@ describe('connectPanePty', () => { agentExitedHandler() - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith('tab-1:1', null) + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 544422456..221ad4aa7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -14,11 +14,7 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal } from './replay-guard' import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script' -import { - paneLeafId, - POST_REPLAY_MODE_RESET, - POST_REPLAY_FOCUS_REPORTING_RESET -} from './layout-serialization' +import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' @@ -28,6 +24,7 @@ import { waitForTerminalOutputParsed, writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' const pendingSpawnByPaneKey = new Map>() @@ -153,10 +150,10 @@ export function connectPanePty( const paneStartup = deps.startup ?? null deps.startup = undefined - // Why: cache timer state is keyed per-pane (not per-tab) so split-pane tabs - // can track each Claude session independently without overwriting each other. - const cacheKey = `${deps.tabId}:${pane.id}` - const pendingSpawnKey = `${deps.tabId}:${paneLeafId(pane.id)}` + // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. + // Use the stable layout leaf UUID, not the renderer-local numeric pane id. + const cacheKey = makePaneKey(deps.tabId, pane.leafId) + const pendingSpawnKey = cacheKey const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { const state = useAppStore.getState() @@ -327,9 +324,8 @@ export function connectPanePty( } // Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their // callbacks to the correct Orca pane without resolving worktrees from cwd. - // The key matches the `${tabId}:${paneId}` composite used for cacheTimerByKey. - // ORCA_TAB_ID / ORCA_WORKTREE_ID are exposed separately so the receiver has - // routing context without having to split paneKey back into its parts. + // The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey + // and agentStatusByPaneKey. Treat it as opaque outside Orca. const paneEnv = { ...paneStartup?.env, ORCA_PANE_KEY: cacheKey, @@ -368,7 +364,7 @@ export function connectPanePty( // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined // and the main-side guard short-circuits. tabId: deps.tabId, - leafId: paneLeafId(pane.id), + leafId: pane.leafId, ...(shellOverride ? { shellOverride } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, @@ -714,7 +710,7 @@ export function connectPanePty( warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', { tabId: deps.tabId, worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? paneLeafId(pane.id), + leafId: deps.restoredLeafId ?? pane.leafId, paneId: pane.id, ptyId: staleSessionId ?? null }) @@ -1163,7 +1159,7 @@ export function connectPanePty( warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? paneLeafId(pane.id), + leafId: deps.restoredLeafId ?? pane.leafId, paneId: pane.id, ptyId: deferredReattachSessionId, reason: message @@ -1212,11 +1208,9 @@ export function connectPanePty( } } else { allowInitialIdleCacheSeed = false - const pendingSpawn = hasExistingPaneTransport - ? undefined - : pendingSpawnByPaneKey.get(pendingSpawnKey) + const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey) if (pendingSpawn) { - console.log(`[pty-connect] pane=${pane.id} → PENDING SPAWN (waiting on sibling)`) + console.log(`[pty-connect] pane=${pane.id} → PENDING SPAWN (waiting on same leaf)`) ;((globalThis as Record).__ptyConnectDiag as string[])?.push( `pane=${pane.id} → PENDING SPAWN` ) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 252c2f018..1164b4392 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -666,7 +666,7 @@ describe('createRemoteRuntimePtyTransport', () => { command: 'claude', env: { ORCA_TAB_ID: 'tab-1' }, tabId: 'tab-1', - leafId: 'pane:1' + leafId: '11111111-1111-4111-8111-111111111111' }) const result = await transport.connect({ @@ -682,6 +682,8 @@ describe('createRemoteRuntimePtyTransport', () => { worktree: 'repo1::/remote/wt', command: 'claude', env: { ORCA_TAB_ID: 'tab-1' }, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', focus: false }, timeoutMs: 15_000 diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 5f2d890a2..c42dd65df 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -201,6 +201,8 @@ export function createRemoteRuntimePtyTransport( worktree: worktreeId, command, env, + tabId, + leafId, focus: false }) handle = created.terminal.handle diff --git a/src/renderer/src/components/terminal-pane/stale-agent-row.ts b/src/renderer/src/components/terminal-pane/stale-agent-row.ts new file mode 100644 index 000000000..e5cf1b209 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-agent-row.ts @@ -0,0 +1,20 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +export function dismissStaleAgentRowByKey(paneKey: string): void { + const store = useAppStore.getState() + const liveExisted = paneKey in store.agentStatusByPaneKey + const retainedExisted = paneKey in store.retainedAgentsByPaneKey + store.dropAgentStatus(paneKey) + store.dismissRetainedAgent(paneKey) + if (liveExisted || retainedExisted) { + toast.info("Agent's pane is no longer available.", { + id: `stale-agent-row-${paneKey}` + }) + } +} + +export function surfaceStaleAgentRow(tabId: string, leafId: string): void { + dismissStaleAgentRowByKey(makePaneKey(tabId, leafId)) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts new file mode 100644 index 000000000..b04b23db0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -0,0 +1,173 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id' +import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id' + +const EMPTY_TERMINAL_LAYOUT: TerminalLayoutSnapshot = { + root: null, + activeLeafId: null, + expandedLeafId: null +} + +type LeafIdRewrite = { + nextLeafIdByInputLeafId: Map + duplicatedInputLeafIds: Set +} + +function cloneLayoutWithLeafRewrite( + node: TerminalPaneLayoutNode, + rewrite: LeafIdRewrite +): TerminalPaneLayoutNode { + if (node.type === 'leaf') { + const replacement = rewrite.nextLeafIdByInputLeafId.get(node.leafId) ?? mintStablePaneId() + return { type: 'leaf', leafId: replacement } + } + return { + ...node, + first: cloneLayoutWithLeafRewrite(node.first, rewrite), + second: cloneLayoutWithLeafRewrite(node.second, rewrite) + } +} + +function remapLeafRecord( + source: Record | undefined, + rewrite: LeafIdRewrite +): Record | undefined { + if (!source) { + return undefined + } + const next: Record = {} + for (const [leafId, value] of Object.entries(source)) { + if (rewrite.duplicatedInputLeafIds.has(leafId)) { + continue + } + const nextLeafId = rewrite.nextLeafIdByInputLeafId.get(leafId) + if (nextLeafId) { + next[nextLeafId] = value + } + } + return Object.keys(next).length > 0 ? next : undefined +} + +function collectLeafCounts( + node: TerminalPaneLayoutNode, + counts: Map = new Map() +): Map { + if (node.type === 'leaf') { + counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) + return counts + } + collectLeafCounts(node.first, counts) + collectLeafCounts(node.second, counts) + return counts +} + +function firstLeafId(node: TerminalPaneLayoutNode | null): string | null { + if (!node) { + return null + } + return node.type === 'leaf' ? node.leafId : firstLeafId(node.first) +} + +export function normalizeTerminalLayoutSnapshot( + snapshot: TerminalLayoutSnapshot | null | undefined +): { snapshot: TerminalLayoutSnapshot; changed: boolean } { + if (!snapshot?.root) { + return { snapshot: snapshot ?? EMPTY_TERMINAL_LAYOUT, changed: false } + } + const counts = collectLeafCounts(snapshot.root) + const duplicatedInputLeafIds = new Set( + Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([leafId]) => leafId) + ) + const nextLeafIdByInputLeafId = new Map() + let changed = false + for (const [leafId, count] of counts) { + if (count === 1 && isTerminalLeafId(leafId)) { + nextLeafIdByInputLeafId.set(leafId, leafId) + continue + } + changed = true + if (count === 1) { + nextLeafIdByInputLeafId.set(leafId, mintStablePaneId()) + } + } + if (!changed) { + return { snapshot, changed: false } + } + const rewrite: LeafIdRewrite = { nextLeafIdByInputLeafId, duplicatedInputLeafIds } + const root = cloneLayoutWithLeafRewrite(snapshot.root, rewrite) + const activeLeafId = + snapshot.activeLeafId && !duplicatedInputLeafIds.has(snapshot.activeLeafId) + ? (nextLeafIdByInputLeafId.get(snapshot.activeLeafId) ?? null) + : firstLeafId(root) + const expandedLeafId = + snapshot.expandedLeafId && !duplicatedInputLeafIds.has(snapshot.expandedLeafId) + ? (nextLeafIdByInputLeafId.get(snapshot.expandedLeafId) ?? null) + : null + const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite) + const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite) + const titlesByLeafId = remapLeafRecord(snapshot.titlesByLeafId, rewrite) + const { + ptyIdsByLeafId: _oldPtyIdsByLeafId, + buffersByLeafId: _oldBuffersByLeafId, + titlesByLeafId: _oldTitlesByLeafId, + ...snapshotWithoutLeafRecords + } = snapshot + return { + snapshot: { + ...snapshotWithoutLeafRecords, + root, + activeLeafId, + expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + }, + changed: true + } +} + +export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + if (node.type === 'leaf') { + return [node.leafId] + } + return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)] +} + +export function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { + return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) +} + +function collectReplayCreatedPaneLeafIds( + node: Extract, + leafIdsInReplayCreationOrder: string[] +): void { + // Why: replayTerminalLayout() creates one new pane per split and assigns it + // to the split's second subtree before recursing, so the new pane maps to + // the leftmost leaf reachable within that second subtree. + leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second)) + + if (node.first.type === 'split') { + collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder) + } + if (node.second.type === 'split') { + collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder) + } +} + +export function collectLeafIdsInReplayCreationOrder( + node: TerminalPaneLayoutNode | null | undefined +): string[] { + if (!node) { + return [] + } + const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)] + if (node.type === 'split') { + collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder) + } + return leafIdsInReplayCreationOrder +} diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 87a5a3ad3..e9608dcd1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -1,6 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../../shared/types' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const + const mocks = vi.hoisted(() => ({ flushTerminalOutput: vi.fn() })) @@ -40,8 +42,11 @@ beforeEach(() => { mocks.flushTerminalOutput.mockReset() }) -function mockRootForPane(paneId: number): HTMLDivElement { - const pane = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(paneId) } }) +function mockRootForPane(paneId: number, leafId: string = LEAF_ID): HTMLDivElement { + const pane = new MockHTMLElement({ + classList: ['pane'], + dataset: { paneId: String(paneId), leafId } + }) return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement } @@ -55,6 +60,8 @@ describe('captureTerminalShutdownLayout', () => { } const pane = { id: 1, + leafId: LEAF_ID, + stablePaneId: LEAF_ID, terminal, serializeAddon: { serialize: vi.fn(() => { @@ -75,7 +82,7 @@ describe('captureTerminalShutdownLayout', () => { const layout = captureTerminalShutdownLayout({ manager: manager as never, - container: mockRootForPane(1), + container: mockRootForPane(1, LEAF_ID), expandedPaneId: null, paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]), paneTitlesByPaneId: { 1: 'build logs' }, @@ -84,12 +91,12 @@ describe('captureTerminalShutdownLayout', () => { expect(order).toEqual(['flush', 'serialize']) expect(layout).toMatchObject({ - root: { type: 'leaf', leafId: 'pane:1' }, - activeLeafId: 'pane:1', + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, expandedLeafId: null, - buffersByLeafId: { 'pane:1': 'snapshot:queued-before-quit' }, - ptyIdsByLeafId: { 'pane:1': 'pty-1' }, - titlesByLeafId: { 'pane:1': 'build logs' } + buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' }, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }, + titlesByLeafId: { [LEAF_ID]: 'build logs' } }) }) @@ -97,6 +104,8 @@ describe('captureTerminalShutdownLayout', () => { const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') const pane = { id: 1, + leafId: LEAF_ID, + stablePaneId: LEAF_ID, terminal: { options: { scrollback: 50_000 } }, serializeAddon: { serialize: vi.fn(() => 'x'.repeat(512 * 1024)) @@ -117,7 +126,7 @@ describe('captureTerminalShutdownLayout', () => { root: null, activeLeafId: null, expandedLeafId: null, - buffersByLeafId: { 'pane:1': 'previous-local-scrollback' } + buffersByLeafId: { [LEAF_ID]: 'previous-local-scrollback' } }, captureBuffers: false }) @@ -125,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => { expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() expect(pane.serializeAddon.serialize).not.toHaveBeenCalled() expect(layout.buffersByLeafId).toBeUndefined() - expect(layout.ptyIdsByLeafId).toEqual({ 'pane:1': 'pty-1' }) - expect(layout.titlesByLeafId).toEqual({ 'pane:1': 'local shell' }) + expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' }) + expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' }) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 67f9d9bb4..709af1fee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -2,12 +2,12 @@ import type { TerminalLayoutSnapshot } from '../../../../shared/types' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { paneLeafId, serializeTerminalLayout } from './layout-serialization' +import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' const MAX_BUFFER_BYTES = 512 * 1024 -type ShutdownPane = Pick +type ShutdownPane = Pick type ShutdownPaneManager = { getPanes(): ShutdownPane[] @@ -42,7 +42,7 @@ export function captureTerminalShutdownLayout({ // Why: non-focused panes may have renderer-throttled PTY bytes queued; // push them into xterm before taking the shutdown scrollback snapshot. flushTerminalOutput(pane.terminal) - const leafId = paneLeafId(pane.id) + const leafId = pane.leafId let scrollback = pane.terminal.options.scrollback ?? 10_000 let serialized = pane.serializeAddon.serialize({ scrollback }) // Cap at 512KB — binary search for largest scrollback that fits. @@ -72,11 +72,16 @@ export function captureTerminalShutdownLayout({ } const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null - const layout = serializeTerminalLayout(container, activePaneId, expandedPaneId) - const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id))) + const layout = serializeTerminalLayout( + container, + activePaneId, + expandedPaneId, + new Map(panes.map((pane) => [pane.id, pane.leafId])) + ) + const currentLeafIds = new Set(panes.map((p) => p.leafId)) const ptyEntries = panes - .map((pane) => [paneLeafId(pane.id), paneTransports.get(pane.id)?.getPtyId() ?? null] as const) - .filter((entry): entry is readonly [string, string] => entry[1] !== null) + .map((pane) => [pane.leafId, paneTransports.get(pane.id)?.getPtyId() ?? null] as const) + .filter((entry): entry is readonly [ShutdownPane['leafId'], string] => entry[1] !== null) const mergedBuffers = captureBuffers ? mergeCapturedLeafState({ @@ -99,7 +104,7 @@ export function captureTerminalShutdownLayout({ const titleEntries = panes .filter((p) => paneTitlesByPaneId[p.id]) - .map((p) => [paneLeafId(p.id), paneTitlesByPaneId[p.id]] as const) + .map((p) => [p.leafId, paneTitlesByPaneId[p.id]] as const) if (titleEntries.length > 0) { layout.titlesByLeafId = Object.fromEntries(titleEntries) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index d739d4727..c2ecd12ce 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -12,6 +12,9 @@ import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' +import { surfaceStaleAgentRow } from './stale-agent-row' +import { useAppStore } from '@/store' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -114,18 +117,12 @@ export function useTerminalPaneGlobalEffects({ useEffect(() => { const onFocusPane = (event: Event): void => { const detail = (event as CustomEvent).detail - if (!detail?.tabId || detail.tabId !== tabId) { - return - } - const manager = managerRef.current - if (!manager) { - return - } - const pane = manager.getPanes().find((candidate) => candidate.id === detail.paneId) - if (!pane) { - return - } - manager.setActivePane(pane.id, { focus: true }) + handleFocusTerminalPaneDetail(detail, { + tabId, + manager: managerRef.current, + acknowledgeAgents: (paneKeys) => useAppStore.getState().acknowledgeAgents(paneKeys), + surfaceStaleAgentRow + }) } window.addEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane) return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index af06537c4..20ab6b285 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -19,10 +19,11 @@ import type { EventProps } from '../../../../shared/telemetry-events' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, - collectLeafIdsInReplayCreationOrder, + normalizeTerminalLayoutSnapshot, replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' import { applyTerminalAppearance, @@ -335,11 +336,12 @@ export function useTerminalPaneLifecycle({ setPaneCount(managerRef.current?.getPanes().length ?? 0) } + const normalizedInitialLayout = normalizeTerminalLayoutSnapshot(initialLayoutRef.current) + if (normalizedInitialLayout.changed) { + initialLayoutRef.current = normalizedInitialLayout.snapshot + useAppStore.getState().setTabLayout(tabId, normalizedInitialLayout.snapshot) + } let shouldPersistLayout = false - const restoredLeafIdsInCreationOrder = collectLeafIdsInReplayCreationOrder( - initialLayoutRef.current.root - ) - let restoredPaneCreateIndex = 0 const ptyDeps = { tabId, worktreeId, @@ -519,15 +521,13 @@ export function useTerminalPaneLifecycle({ } } applyAppearance(manager) - const restoredLeafId = restoredLeafIdsInCreationOrder[restoredPaneCreateIndex] ?? null - restoredPaneCreateIndex += 1 const panePtyBinding = connectPanePty(pane, manager, { ...ptyDeps, // Why: spread order matters — spawnHints.cwd (inherited from the // source pane) must override the tab-level ptyDeps.cwd (worktree // root) so Cmd+D splits boot in the live cwd. ...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}), - restoredLeafId + restoredLeafId: pane.leafId }) // Why: connectPanePty receives a spread copy of ptyDeps, so the // `deps.startup = undefined` it performs internally only clears its @@ -546,7 +546,7 @@ export function useTerminalPaneLifecycle({ scheduleRuntimeGraphSync() queueResizeAll(true) }, - onPaneClosed: (paneId) => { + onPaneClosed: (paneId, closedPane) => { const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -600,7 +600,12 @@ export function useTerminalPaneLifecycle({ // (not remove) so any retained `done` snapshot for this pane is also // cleared and a same-frame live→gone transition cannot re-snapshot // it via the retention sync. - useAppStore.getState().dropAgentStatus(`${tabId}:${paneId}`) + const leafId = closedPane?.leafId + if (leafId) { + const paneKey = makePaneKey(tabId, leafId) + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + useAppStore.getState().dropAgentStatus(paneKey) + } transport.destroy?.() paneTransportsRef.current.delete(paneId) } diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index bdfa382d1..570685d88 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -24,7 +24,12 @@ export type ToggleTerminalPaneExpandDetail = { export type FocusTerminalPaneDetail = { tabId: string - paneId: number + /** Stable terminal layout leaf UUID. Numeric PaneManager ids are renderer-local + * and can be reminted during replay/reload, so cross-component focus uses + * the durable leaf identity and resolves it at the receiving TerminalPane. */ + leafId: string | null + /** Optional paneKey to ack only after the target leaf resolves and focuses. */ + ackPaneKeyOnSuccess?: string } export type PasteTerminalTextDetail = { diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts index ee92d729a..52ebd4e8d 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAutoAckTargets } from './useAutoAckViewedAgent' import { createTestStore, makeTab } from '../store/slices/store-test-helpers' import type { RetainedAgentEntry } from '../store/slices/agent-status' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const CODEX_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' // Why: regression coverage for the codex inline-agent row that stayed bold // after returning from another workspace (docs/codex-agent-row-bold-stuck.md). @@ -26,8 +30,8 @@ describe('computeAutoAckTargets — codex retain race regression', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z')) const store = createTestStore() - const paneKey = 'tab-codex:2' const activeTabId = 'tab-codex' + const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID) // 1. Codex starts working, user acks it (e.g. by clicking the row). store.getState().setAgentStatus(paneKey, { @@ -72,7 +76,7 @@ describe('computeAutoAckTargets — codex retain race regression', () => { // 5. The user is back on the codex tab. computeAutoAckTargets must see // the retained row and surface it for ack — pre-fix this returned []. - const targets = computeAutoAckTargets(store.getState(), activeTabId) + const targets = computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID) expect(targets).toEqual([paneKey]) }) @@ -80,8 +84,8 @@ describe('computeAutoAckTargets — codex retain race regression', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z')) const store = createTestStore() - const paneKey = 'tab-codex:2' const activeTabId = 'tab-codex' + const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID) store.getState().setAgentStatus(paneKey, { state: 'done', @@ -101,19 +105,19 @@ describe('computeAutoAckTargets — codex retain race regression', () => { store.getState().removeAgentStatus(paneKey) // First scan: the retained row is unvisited. - expect(computeAutoAckTargets(store.getState(), activeTabId)).toEqual([paneKey]) + expect(computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)).toEqual([paneKey]) // Simulate the ack effect. vi.setSystemTime(new Date('2026-05-05T12:00:01.000Z')) store.getState().acknowledgeAgents([paneKey]) // Second scan: idempotent — nothing to ack. - expect(computeAutoAckTargets(store.getState(), activeTabId)).toEqual([]) + expect(computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)).toEqual([]) }) it('skips retained rows whose paneKey is on a different tab', () => { const store = createTestStore() - const paneKey = 'tab-other:0' + const paneKey = makePaneKey('tab-other', OTHER_LEAF_ID) store.getState().setAgentStatus(paneKey, { state: 'done', prompt: 'p', @@ -134,15 +138,15 @@ describe('computeAutoAckTargets — codex retain race regression', () => { // Active tab differs — the retained row must NOT be acked while the user // is looking at a different tab; the bold-until-viewed signal must // survive the tab switch. - expect(computeAutoAckTargets(store.getState(), 'tab-codex')).toEqual([]) + expect(computeAutoAckTargets(store.getState(), 'tab-codex', CODEX_LEAF_ID)).toEqual([]) }) it('acks a paneKey present in BOTH live and retained without throwing', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z')) const store = createTestStore() - const paneKey = 'tab-codex:2' const activeTabId = 'tab-codex' + const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID) // Construct a (rare) state where retainedAgentsByPaneKey and // agentStatusByPaneKey both contain the same paneKey — e.g. the @@ -165,7 +169,7 @@ describe('computeAutoAckTargets — codex retain race regression', () => { } ]) - const targets = computeAutoAckTargets(store.getState(), activeTabId) + const targets = computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID) // Two pushes, same paneKey — duplicates are intentional and harmless; // acknowledgeAgents short-circuits per key. expect(targets.length).toBeLessThanOrEqual(2) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 2209c5540..25cb244a6 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -2,17 +2,26 @@ import { useEffect } from 'react' import { useAppStore } from '@/store' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import type { TerminalLayoutSnapshot } from '../../../shared/types' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' + +function resolveActiveLeafId( + state: { terminalLayoutsByTabId: Record }, + activeTabId: string +): string | null { + const leafId = state.terminalLayoutsByTabId[activeTabId]?.activeLeafId ?? null + return leafId && isTerminalLeafId(leafId) ? leafId : null +} /** * Pure helper used by the hook below — exported so the regression test for * the codex-row-stays-bold race (docs/codex-agent-row-bold-stuck.md) can * exercise the decision against a real test store without needing a DOM. * - * Returns the list of paneKeys that should be acked given the active tab. - * Walks BOTH the live agent map AND the retained snapshot map: the inline - * agents list renders the union, so the ack scan must too. A paneKey may - * appear in both maps simultaneously (paneKey reuse mid-frame); duplicate - * pushes are harmless because acknowledgeAgents short-circuits per key. + * Returns the list of paneKeys that should be acked given the active tab and + * exact active leaf. Split tabs can host multiple agent panes, so equality on + * `${tabId}:${leafId}` is required; tab-prefix matching would mark siblings + * read without ever displaying them. */ export function computeAutoAckTargets( state: { @@ -20,29 +29,29 @@ export function computeAutoAckTargets( retainedAgentsByPaneKey: Record acknowledgedAgentsByPaneKey: Record }, - activeTabId: string + activeTabId: string, + activeLeafId: string | null ): string[] { - const prefix = `${activeTabId}:` + if (!activeLeafId || !isTerminalLeafId(activeLeafId)) { + return [] + } + const targetKey = makePaneKey(activeTabId, activeLeafId) const targets: string[] = [] - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (!paneKey.startsWith(prefix)) { - continue - } - const ackAt = state.acknowledgedAgentsByPaneKey[paneKey] ?? 0 + const liveEntry = state.agentStatusByPaneKey[targetKey] + if (liveEntry) { + const ackAt = state.acknowledgedAgentsByPaneKey[targetKey] ?? 0 // Why: use stateStartedAt (not updatedAt) so tool/prompt pings within the // same state don't re-trigger ack work — keeping the comparison aligned // with WorktreeCardAgents' is-unvisited rule. - if (ackAt < entry.stateStartedAt) { - targets.push(paneKey) + if (ackAt < liveEntry.stateStartedAt) { + targets.push(targetKey) } } - for (const [paneKey, retained] of Object.entries(state.retainedAgentsByPaneKey)) { - if (!paneKey.startsWith(prefix)) { - continue - } - const ackAt = state.acknowledgedAgentsByPaneKey[paneKey] ?? 0 + const retained = state.retainedAgentsByPaneKey[targetKey] + if (retained) { + const ackAt = state.acknowledgedAgentsByPaneKey[targetKey] ?? 0 if (ackAt < retained.entry.stateStartedAt) { - targets.push(paneKey) + targets.push(targetKey) } } return targets @@ -102,6 +111,7 @@ export function useAutoAckViewedAgent(): void { let lastAgentStatus: unknown = undefined let lastRetained: unknown = undefined let lastAcknowledged: unknown = undefined + let lastLayouts: unknown = undefined const maybeAck = (): void => { const s = useAppStore.getState() @@ -110,7 +120,8 @@ export function useAutoAckViewedAgent(): void { s.activeTabId === lastActiveTabId && s.agentStatusByPaneKey === lastAgentStatus && s.retainedAgentsByPaneKey === lastRetained && - s.acknowledgedAgentsByPaneKey === lastAcknowledged + s.acknowledgedAgentsByPaneKey === lastAcknowledged && + s.terminalLayoutsByTabId === lastLayouts ) { return } @@ -136,6 +147,7 @@ export function useAutoAckViewedAgent(): void { if (!activeTabId) { return } + const activeLeafId = resolveActiveLeafId(s, activeTabId) // Why: advance the refs ONLY after all gates have passed — if the // visibility gate (window hidden/unfocused or no activeTabId) caused an // early return, leave the refs stale so the next call (e.g. triggered by @@ -148,7 +160,8 @@ export function useAutoAckViewedAgent(): void { lastAgentStatus = s.agentStatusByPaneKey lastRetained = s.retainedAgentsByPaneKey lastAcknowledged = s.acknowledgedAgentsByPaneKey - const toAck = computeAutoAckTargets(s, activeTabId) + lastLayouts = s.terminalLayoutsByTabId + const toAck = computeAutoAckTargets(s, activeTabId, activeLeafId) if (toAck.length > 0) { s.acknowledgeAgents(toAck) } diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index e833e8536..b6fdf1a6e 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session' import { useAppStore } from '@/store' import type { AutomationDispatchResult } from '../../../shared/automations-types' -import { FIRST_PANE_ID } from '../../../shared/pane-key' +import { parsePaneKey } from '../../../shared/stable-pane-id' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' @@ -151,10 +151,14 @@ export function useAutomationDispatchEvents(): void { void markCompletionResult() } const observeAgentStatus = (tabId: string): void => { - const paneKey = `${tabId}:${FIRST_PANE_ID}` const checkCurrentStatus = (): void => { - if (useAppStore.getState().agentStatusByPaneKey[paneKey]?.state === 'done') { - handleAgentDone() + const { agentStatusByPaneKey } = useAppStore.getState() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + const parsed = parsePaneKey(paneKey) + if (parsed?.tabId === tabId && entry.state === 'done') { + handleAgentDone() + return + } } } // Why: Codex/Claude completion normally arrives through the global diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 9e412a5cd..f5d927fe6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2,6 +2,16 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveZoomTarget } from './useIpcEvents' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const FUTURE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const STALE_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const ORPHAN_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const TAB_1_LEAF_ID = '44444444-4444-4444-8444-444444444444' +const FUTURE_PANE_KEY = makePaneKey('tab-future', FUTURE_LEAF_ID) +const STALE_PANE_KEY = makePaneKey('tab-future', STALE_LEAF_ID) +const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', ORPHAN_LEAF_ID) +const TAB_1_PANE_KEY = makePaneKey('tab-1', TAB_1_LEAF_ID) function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): { classList: { contains: (token: string) => boolean } @@ -1672,6 +1682,7 @@ describe('useIpcEvents agent status snapshot integration', () => { removeSshCredentialRequest: vi.fn(), clearTabPtyId: vi.fn(), runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: {}, repos: [], worktreesByRepo: {}, tabsByWorktree: {}, @@ -1822,7 +1833,7 @@ describe('useIpcEvents agent status snapshot integration', () => { const getSnapshot = vi.fn(() => Promise.resolve([ { - paneKey: 'tab-future:0', + paneKey: FUTURE_PANE_KEY, state: 'working' as const, prompt: 'p', agentType: 'claude', @@ -1877,7 +1888,7 @@ describe('useIpcEvents agent status snapshot integration', () => { // Fire an event for an unknown paneKey while not ready — must NOT call setAgentStatus. onSetListenerRef.current({ - paneKey: 'tab-future:0', + paneKey: FUTURE_PANE_KEY, state: 'working', prompt: 'p', agentType: 'claude', @@ -1891,6 +1902,13 @@ describe('useIpcEvents agent status snapshot integration', () => { storeState.tabsByWorktree = { 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] } + storeState.terminalLayoutsByTabId = { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } if (typeof subscribeListenerRef.current !== 'function') { throw new Error('Expected useAppStore.subscribe listener to be registered') } @@ -1899,7 +1917,7 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).toHaveBeenCalledTimes(1) expect(setAgentStatus).toHaveBeenCalledWith( - 'tab-future:0', + FUTURE_PANE_KEY, expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), 'Future Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } @@ -2010,7 +2028,7 @@ describe('useIpcEvents agent status snapshot integration', () => { const getSnapshot = vi.fn(() => Promise.resolve([ { - paneKey: 'tab-orphan:0', + paneKey: ORPHAN_PANE_KEY, state: 'done' as const, prompt: 'p', agentType: 'claude', @@ -2025,6 +2043,68 @@ describe('useIpcEvents agent status snapshot integration', () => { tabsByWorktree: { 'wt-1': [{ id: 'tab-other', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Other' }] }, + terminalLayoutsByTabId: { + 'tab-orphan': { + root: { type: 'leaf', leafId: ORPHAN_LEAF_ID }, + activeLeafId: ORPHAN_LEAF_ID, + expandedLeafId: null + } + }, + workspaceSessionReady: true + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + await Promise.resolve() + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + + it('silently discards valid paneKeys whose leaf is not in the current layout', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => + Promise.resolve([ + { + paneKey: STALE_PANE_KEY, + state: 'done' as const, + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ]) + ) + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, workspaceSessionReady: true }) @@ -2067,6 +2147,13 @@ describe('useIpcEvents agent status snapshot integration', () => { }, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TAB_1_LEAF_ID }, + activeLeafId: TAB_1_LEAF_ID, + expandedLeafId: null + } } }) @@ -2090,7 +2177,7 @@ describe('useIpcEvents agent status snapshot integration', () => { await Promise.resolve() onSetListenerRef.current?.({ - paneKey: 'tab-1:0', + paneKey: TAB_1_PANE_KEY, connectionId: 'conn-1', state: 'working', receivedAt: 1_700_000_000_100, @@ -2098,7 +2185,7 @@ describe('useIpcEvents agent status snapshot integration', () => { }) expect(setAgentStatus).toHaveBeenCalledWith( - 'tab-1:0', + TAB_1_PANE_KEY, expect.objectContaining({ state: 'working' }), 'Terminal 1', { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 } @@ -2119,6 +2206,13 @@ describe('useIpcEvents agent status snapshot integration', () => { }, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TAB_1_LEAF_ID }, + activeLeafId: TAB_1_LEAF_ID, + expandedLeafId: null + } } }) @@ -2142,7 +2236,7 @@ describe('useIpcEvents agent status snapshot integration', () => { await Promise.resolve() onSetListenerRef.current?.({ - paneKey: 'tab-1:0', + paneKey: TAB_1_PANE_KEY, connectionId: 'conn-stale', state: 'working', receivedAt: 1_700_000_000_100, @@ -2164,6 +2258,13 @@ describe('useIpcEvents agent status snapshot integration', () => { worktreesByRepo: { 'repo-1': [] }, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TAB_1_LEAF_ID }, + activeLeafId: TAB_1_LEAF_ID, + expandedLeafId: null + } } }) @@ -2187,7 +2288,7 @@ describe('useIpcEvents agent status snapshot integration', () => { await Promise.resolve() onSetListenerRef.current?.({ - paneKey: 'tab-1:0', + paneKey: TAB_1_PANE_KEY, connectionId: 'conn-other', state: 'working', receivedAt: 1_700_000_000_100, @@ -2211,6 +2312,13 @@ describe('useIpcEvents agent status snapshot integration', () => { }, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TAB_1_LEAF_ID }, + activeLeafId: TAB_1_LEAF_ID, + expandedLeafId: null + } } }) @@ -2234,7 +2342,7 @@ describe('useIpcEvents agent status snapshot integration', () => { await Promise.resolve() onSetListenerRef.current?.({ - paneKey: 'tab-1:0', + paneKey: TAB_1_PANE_KEY, state: 'working', receivedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 53cf19d73..fdeedcf36 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -34,7 +34,10 @@ import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-drive import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { detectLanguage } from '@/lib/language-detect' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' import { track } from '@/lib/telemetry' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' export { resolveZoomTarget } from './resolve-zoom-target' @@ -276,7 +279,7 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onCreateTerminal( - ({ requestId, worktreeId, command, title, ptyId, activate, tabId }) => { + ({ requestId, worktreeId, command, title, ptyId, activate, tabId, leafId }) => { try { if (isRuntimeEnvironmentActive()) { if (requestId) { @@ -308,17 +311,15 @@ export function useIpcEvents(): void { initialPtyId: ptyId, activate: shouldActivate, // Why: tabId hint comes from CLI-spawned PTYs whose env - // already has paneKey=`${tabId}:1` baked in. Adopting the - // tab under the same id keeps hook-event attribution working; - // see docs/cli-terminal-hook-pane-key.md. + // already has the pane key baked in. Adopting the tab under + // the same id keeps hook-event attribution working. ...(tabId !== undefined ? { id: tabId } : {}) })) : store.createTab(worktreeId) // Why: when an existing tab already owns this ptyId, we reuse it instead of - // minting a new one — but the PTY env already carries `paneKey=`${tabId}:1`` - // from main. If the existing tab id doesn't match the hint, hook attribution - // will degrade for that PTY's lifetime. Warn so this is visible during - // development; in production this surfaces via `agent_hook_unattributed`. + // minting a new one — but the PTY env already carries a paneKey from main. + // If the existing tab id doesn't match the hint, hook attribution degrades + // for that PTY's lifetime. Warn so this is visible during development. if (tabId !== undefined && tab.id !== tabId) { console.warn( `[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)` @@ -332,6 +333,12 @@ export function useIpcEvents(): void { if (title) { store.setTabCustomTitle(tab.id, title) } + if (leafId && ptyId) { + // Why: CLI/runtime-spawned PTYs emit hook events before a hidden + // tab mounts TerminalPane, so the adopted UUID leaf must exist + // in layout state for paneKey validation to accept them. + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) + } if (command) { store.queueTabStartupCommand(tab.id, { command }) } @@ -455,7 +462,7 @@ export function useIpcEvents(): void { store.setActiveTab(tabId) store.revealWorktreeInSidebar(worktreeId) if (!focusRuntimeTerminalSurface(tabId, leafId)) { - focusTerminalTabSurface(tabId) + focusTerminalTabSurface(tabId, leafId) } }) ) @@ -1178,6 +1185,22 @@ export function useIpcEvents(): void { for (const entry of entries) { applyAgentStatus(entry, { replay: true }) } + const getMigrationUnsupportedSnapshot = + window.api.agentStatus.getMigrationUnsupportedSnapshot + if (typeof getMigrationUnsupportedSnapshot !== 'function') { + return + } + void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { + const unsupportedStore = useAppStore.getState() + if (!unsupportedStore.workspaceSessionReady) { + return + } + for (const entry of unsupportedEntries) { + if (entry.paneKey && resolvePaneKey(unsupportedStore, entry.paneKey).exists) { + unsupportedStore.setMigrationUnsupportedPty(entry) + } + } + }) }) .catch((err) => { // Why: keep snapshotRequestedForReadyWindow latched on failure. The @@ -1196,6 +1219,27 @@ export function useIpcEvents(): void { applyAgentStatus(data) }) ) + const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( + (entry) => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + return + } + if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) { + store.setMigrationUnsupportedPty(entry) + } + } + ) + if (unsubscribeMigrationUnsupported) { + unsubs.push(unsubscribeMigrationUnsupported) + } + const unsubscribeMigrationUnsupportedClear = + window.api.agentStatus.onMigrationUnsupportedClear?.(({ ptyId }) => { + useAppStore.getState().clearMigrationUnsupportedPty(ptyId) + }) + if (unsubscribeMigrationUnsupportedClear) { + unsubs.push(unsubscribeMigrationUnsupportedClear) + } // Why: the main hook server is the durable source of truth. Pull a // snapshot only after workspace tabs are ready, so early startup pushes @@ -1291,7 +1335,7 @@ export function useIpcEvents(): void { }, []) } -/** Resolve a paneKey (tabId:paneId) to a liveness check, the current terminal +/** Resolve a paneKey (tabId:leafId) to both a liveness check and the current * title, and the connectionId of the repo that owns the pane's worktree. * Walks tabsByWorktree to locate the tab, then resolves the owning worktree * and repo via cached selector maps. Used for agent type inference when the @@ -1306,16 +1350,19 @@ function resolvePaneKey( store: ReturnType, paneKey: string ): { exists: boolean; title: string | undefined; repoConnectionId: string | null } { - const [tabId, paneIdRaw] = paneKey.split(':') - if (!tabId) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { return { exists: false, title: undefined, repoConnectionId: null } } - // Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer - // the pane's own title over the tab-level (last-winning) title so agent type - // inference attributes status to the correct pane. - const paneTitles = store.runtimePaneTitlesByTabId?.[tabId] - const paneIdNum = paneIdRaw !== undefined ? Number(paneIdRaw) : NaN - const rawPaneTitle = paneTitles && !Number.isNaN(paneIdNum) ? paneTitles[paneIdNum] : undefined + const { tabId, leafId } = parsed + const layout = store.terminalLayoutsByTabId?.[tabId] + const leafExists = collectLeafIdsInOrder(layout?.root).includes(leafId) + if (!leafExists) { + return { exists: false, title: undefined, repoConnectionId: null } + } + // Why: replay can remint numeric pane ids, so status title recovery must use + // persisted leaf-keyed titles when crossing from hook state into tab state. + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] // Why: treat an empty-string paneTitle as "no title" so the tab-level // fallback still fires. `paneTitle ?? tabTitle` alone would short-circuit on // '' and also erase any previously-cached terminalTitle in the store diff --git a/src/renderer/src/lib/activate-tab-and-focus-pane.ts b/src/renderer/src/lib/activate-tab-and-focus-pane.ts index 864b62928..2a8ef0710 100644 --- a/src/renderer/src/lib/activate-tab-and-focus-pane.ts +++ b/src/renderer/src/lib/activate-tab-and-focus-pane.ts @@ -1,17 +1,26 @@ import { useAppStore } from '@/store' import { FOCUS_TERMINAL_PANE_EVENT, type FocusTerminalPaneDetail } from '@/constants/terminal' -export function activateTabAndFocusPane(tabId: string, paneId: number | null): void { +export function activateTabAndFocusPane( + tabId: string, + leafId: string | null, + opts?: { ackPaneKeyOnSuccess?: string } +): void { useAppStore.getState().setActiveTab(tabId) - if (paneId === null) { + if (leafId === null) { return } // Why: defer one frame so the new TerminalPane has mounted its // FOCUS_TERMINAL_PANE_EVENT listener before we dispatch. requestAnimationFrame(() => { + const detail: FocusTerminalPaneDetail = { + tabId, + leafId, + ...(opts?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: opts.ackPaneKeyOnSuccess } : {}) + } window.dispatchEvent( new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, { - detail: { tabId, paneId } + detail }) ) }) diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index ab7f9e70f..060bd8fef 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -5,16 +5,27 @@ * double-rAF waits for that commit so focus lands on the new tab instead of * whatever surface (menu trigger, body, previous tab) just relinquished it. */ -export function focusTerminalTabSurface(tabId: string): void { +function cssAttributeString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void { requestAnimationFrame(() => { requestAnimationFrame(() => { - const scoped = document.querySelector( - `[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea` - ) as HTMLElement | null + const escapedTabId = cssAttributeString(tabId) + const scopedSelector = leafId + ? `[data-terminal-tab-id="${escapedTabId}"] [data-leaf-id="${cssAttributeString(leafId)}"] .xterm-helper-textarea` + : `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea` + const scoped = document.querySelector(scopedSelector) as HTMLElement | null if (scoped) { scoped.focus() return } + if (leafId) { + // Why: exact mobile split-pane focus must not silently focus a sibling + // pane when the requested UUID leaf has not mounted yet. + return + } const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null fallback?.focus() }) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index d8a13516b..af5e113cf 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -13,10 +13,23 @@ const mockCreateTab = vi.fn() const mockSetTabCustomTitle = vi.fn() const mockUpdateTabPtyId = vi.fn() const mockCloseTab = vi.fn() +const mockSetTabLayout = vi.fn() const mockRegisterEagerPtyBuffer = vi.fn() const mockSubscribeToPtyData = vi.fn() const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +function expectStablePaneSpawn(): string { + const spawnArgs = mockSpawn.mock.calls[0]?.[0] + const paneKey = spawnArgs?.env?.ORCA_PANE_KEY + const leafId = spawnArgs?.leafId + expect(typeof paneKey).toBe('string') + expect(typeof leafId).toBe('string') + expect(leafId).toMatch(UUID_RE) + expect(paneKey).toBe(`tab-1:${leafId}`) + return paneKey +} const state = { settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, @@ -28,6 +41,7 @@ const state = { setTabCustomTitle: mockSetTabCustomTitle, updateTabPtyId: mockUpdateTabPtyId, closeTab: mockCloseTab, + setTabLayout: mockSetTabLayout, clearTabPtyId: vi.fn(), setAgentStatus: vi.fn() } @@ -108,15 +122,24 @@ describe('launchAgentBackgroundSession', () => { expect.objectContaining({ cwd: '/repo/worktree', command: "claude 'run the automation'", - env: { - ORCA_PANE_KEY: 'tab-1:1', + env: expect.objectContaining({ ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1' - }, + }), connectionId: null, worktreeId: 'wt-1', - tabId: 'tab-1', - leafId: 'pane:1' + tabId: 'tab-1' + }) + ) + const paneKey = expectStablePaneSpawn() + const leafId = paneKey.slice('tab-1:'.length) + expect(mockSetTabLayout).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: 'pty-1' }, + titlesByLeafId: { [leafId]: 'Nightly audit' } }) ) expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit') @@ -141,8 +164,9 @@ describe('launchAgentBackgroundSession', () => { const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + const paneKey = expectStablePaneSpawn() expect(state.setAgentStatus).toHaveBeenCalledWith( - 'tab-1:1', + paneKey, expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), undefined ) @@ -219,6 +243,18 @@ describe('launchAgentBackgroundSession', () => { }) expect(mockSpawn).not.toHaveBeenCalled() + const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params + const paneKey = params?.env?.ORCA_PANE_KEY + const leafId = typeof paneKey === 'string' ? paneKey.slice('tab-1:'.length) : '' + expect(leafId).toMatch(UUID_RE) + expect(mockSetTabLayout).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' } + }) + ) expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.create', @@ -226,10 +262,12 @@ describe('launchAgentBackgroundSession', () => { worktree: 'wt-1', command: "claude 'run the automation'", env: expect.objectContaining({ - ORCA_PANE_KEY: 'tab-1:1', + ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1' }), + tabId: 'tab-1', + leafId, focus: false }), timeoutMs: 15_000 diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 710a0b577..6240ad8b1 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -7,7 +7,7 @@ import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' -import { FIRST_PANE_ID } from '../../../shared/pane-key' +import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, subscribeToPtyData, @@ -15,6 +15,7 @@ import { } from '@/components/terminal-pane/pty-dispatcher' import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { getRemoteRuntimeTerminalHandle, subscribeToRuntimeTerminalData, @@ -84,9 +85,11 @@ export async function launchAgentBackgroundSession( if (title) { store.setTabCustomTitle(tab.id, title) } - const paneKey = `${tab.id}:${FIRST_PANE_ID}` // Why: agent hook callbacks are keyed by pane, and background automation // tabs never mount a TerminalPane to inject this env for us. + const leafId = globalThis.crypto.randomUUID() + const paneKey = makePaneKey(tab.id, leafId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, undefined, title)) const paneEnv = { ...startupPlan.env, ORCA_PANE_KEY: paneKey, @@ -107,6 +110,8 @@ export async function launchAgentBackgroundSession( command: startupPlan.launchCommand, env: paneEnv, title, + tabId: tab.id, + leafId, focus: false }, { timeoutMs: 15_000 } @@ -122,7 +127,7 @@ export async function launchAgentBackgroundSession( connectionId: repo?.connectionId ?? null, worktreeId, tabId: tab.id, - leafId: 'pane:1', + leafId, telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: launchSource ?? 'unknown', @@ -136,6 +141,7 @@ export async function launchAgentBackgroundSession( throw error } store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) let exitHandled = false let unsubscribeExit = (): void => {} let unsubscribeData = (): void => {} diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.ts new file mode 100644 index 000000000..5f3e83496 --- /dev/null +++ b/src/renderer/src/lib/migration-unsupported-agent-entry.ts @@ -0,0 +1,27 @@ +import type { + AgentStatusEntry, + MigrationUnsupportedPtyEntry +} from '../../../shared/agent-status-types' + +export function migrationUnsupportedToAgentStatusEntry( + entry: MigrationUnsupportedPtyEntry +): AgentStatusEntry | null { + if (!entry.paneKey) { + return null + } + const now = Date.now() + return { + state: 'blocked', + prompt: 'Agent unavailable after pane identity migration', + // Why: this is a persistent migration block, not a hook heartbeat. Keep it + // fresh while present so normal stale-status decay does not hide it. + updatedAt: Math.max(entry.updatedAt, now), + stateStartedAt: entry.updatedAt, + agentType: 'unknown', + paneKey: entry.paneKey, + terminalTitle: 'Migration unsupported', + stateHistory: [], + lastAssistantMessage: + 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.' + } +} diff --git a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts new file mode 100644 index 000000000..10f332f49 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts @@ -0,0 +1,25 @@ +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' + +// Why: Electron/test runtimes can lack crypto.randomUUID. The fallback still +// produces a UUID-shaped v4 id so pane-key validation remains deterministic. +export function mintStablePaneId(): TerminalLeafId { + const cryptoApi = globalThis.crypto as Crypto | undefined + if (cryptoApi?.randomUUID) { + return cryptoApi.randomUUID() as TerminalLeafId + } + const bytes = new Uint8Array(16) + if (cryptoApi?.getRandomValues) { + cryptoApi.getRandomValues(bytes) + } else { + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Math.floor(Math.random() * 256) + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}` as TerminalLeafId +} diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts index 17b26a780..3ab4937c4 100644 --- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts @@ -10,9 +10,13 @@ type FitOverride = { } const overridesByPtyId = new Map() -// Why: keyed by 'tabId:paneId' composite to avoid collisions when different -// tabs have panes with the same numeric ID (pane IDs are per-tab, not global). -const ptyIdByPaneKey = new Map() +// Why: this is an in-memory renderer fit binding, not an agent paneKey. +// Numeric pane ids are valid here because fit overrides never cross replay. +const ptyIdByFitBindingKey = new Map() + +function fitBindingKey(tabId: string, paneId: number): string { + return `${tabId}:${paneId}` +} // Why: the override maps are plain JS — React components that read them // (e.g. the desktop mobile-fit banner) have no way to know when entries @@ -69,7 +73,7 @@ export function setFitOverride( export function getPaneIdsForPty(ptyId: string): number[] { const result: number[] = [] - for (const [key, boundPtyId] of ptyIdByPaneKey) { + for (const [key, boundPtyId] of ptyIdByFitBindingKey) { if (boundPtyId === ptyId) { const paneId = Number(key.split(':').pop()) if (!Number.isNaN(paneId)) { @@ -86,7 +90,7 @@ export function getFitOverrideForPty(ptyId: string): FitOverride | null { export function getFitOverrideForPane(paneId: number, tabId?: string): FitOverride | null { if (tabId) { - const ptyId = ptyIdByPaneKey.get(`${tabId}:${paneId}`) + const ptyId = ptyIdByFitBindingKey.get(fitBindingKey(tabId, paneId)) if (!ptyId) { return null } @@ -97,18 +101,18 @@ export function getFitOverrideForPane(paneId: number, tabId?: string): FitOverri export function bindPanePtyId(paneId: number, ptyId: string | null, tabId?: string): void { if (tabId) { - const key = `${tabId}:${paneId}` + const key = fitBindingKey(tabId, paneId) if (ptyId) { - ptyIdByPaneKey.set(key, ptyId) + ptyIdByFitBindingKey.set(key, ptyId) } else { - ptyIdByPaneKey.delete(key) + ptyIdByFitBindingKey.delete(key) } } } export function unbindPane(paneId: number, tabId?: string): void { if (tabId) { - ptyIdByPaneKey.delete(`${tabId}:${paneId}`) + ptyIdByFitBindingKey.delete(fitBindingKey(tabId, paneId)) } } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index 2085a3fa4..acd20726e 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -33,8 +33,11 @@ function flushAnimationFrames(timestamp = 16): void { } function createPane(): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never return { id: 1, + leafId, + stablePaneId: leafId, terminal: { cols: 79, rows: 24 diff --git a/src/renderer/src/lib/pane-manager/pane-identity-registry.ts b/src/renderer/src/lib/pane-manager/pane-identity-registry.ts new file mode 100644 index 000000000..0c1951e97 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-identity-registry.ts @@ -0,0 +1,79 @@ +import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id' +import { mintStablePaneId } from './mint-stable-pane-id' +import type { ManagedPaneInternal } from './pane-manager-types' + +export class PaneIdentityRegistry { + private leafIdByNumericId: Map = new Map() + private numericIdByLeafId: Map = new Map() + private publishedPaneIds: Set = new Set() + + claimLeafId(leafIdHint?: string): TerminalLeafId { + if (leafIdHint && isTerminalLeafId(leafIdHint) && !this.numericIdByLeafId.has(leafIdHint)) { + return leafIdHint + } + return this.mintUnclaimedLeafId() + } + + register(numericPaneId: number, leafId: TerminalLeafId): void { + this.leafIdByNumericId.set(numericPaneId, leafId) + this.numericIdByLeafId.set(leafId, numericPaneId) + } + + release(numericPaneId: number): void { + const leafId = this.leafIdByNumericId.get(numericPaneId) + if (leafId) { + this.numericIdByLeafId.delete(leafId) + } + this.leafIdByNumericId.delete(numericPaneId) + this.publishedPaneIds.delete(numericPaneId) + } + + markPublished(numericPaneId: number): void { + this.publishedPaneIds.add(numericPaneId) + } + + getLeafId(numericPaneId: number): TerminalLeafId | null { + return this.leafIdByNumericId.get(numericPaneId) ?? null + } + + getNumericIdForLeaf(leafId: string): number | null { + if (!isTerminalLeafId(leafId)) { + return null + } + return this.numericIdByLeafId.get(leafId) ?? null + } + + getLeafIdMap(): Map { + return new Map(this.leafIdByNumericId) + } + + adoptPaneLeafId(numericPaneId: number, pane: ManagedPaneInternal, leafId: string): boolean { + if (!isTerminalLeafId(leafId) || this.publishedPaneIds.has(numericPaneId)) { + return false + } + const existingOwner = this.numericIdByLeafId.get(leafId) + if (existingOwner !== undefined && existingOwner !== numericPaneId) { + return false + } + this.numericIdByLeafId.delete(pane.leafId) + this.register(numericPaneId, leafId) + pane.leafId = leafId + pane.stablePaneId = leafId + pane.container.dataset.leafId = leafId + return true + } + + clear(): void { + this.leafIdByNumericId.clear() + this.numericIdByLeafId.clear() + this.publishedPaneIds.clear() + } + + private mintUnclaimedLeafId(): TerminalLeafId { + let leafId: TerminalLeafId + do { + leafId = mintStablePaneId() + } while (this.numericIdByLeafId.has(leafId)) + return leafId + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts b/src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts new file mode 100644 index 000000000..d75f89c9e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { ManagedPane } from './pane-manager-types' +import { resolveLeafIdForManager, resolvePaneKeyForManager } from './pane-key-resolution' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) + +function makeManager(args: { + numericPaneId: number | null + panes: Pick[] +}) { + return { + getNumericIdForLeaf: () => args.numericPaneId, + getPanes: () => args.panes as ManagedPane[] + } +} + +describe('pane-key resolution', () => { + it('resolves a stable pane key to the current numeric pane id', () => { + const manager = makeManager({ + numericPaneId: 7, + panes: [{ id: 7, leafId: LEAF_ID as never }] + }) + + expect(resolvePaneKeyForManager('tab-1', PANE_KEY, manager)).toEqual({ + status: 'resolved', + paneKey: PANE_KEY, + leafId: LEAF_ID, + numericPaneId: 7 + }) + }) + + it('rejects malformed, legacy numeric, and wrong-tab pane keys as invalid', () => { + const manager = makeManager({ + numericPaneId: 1, + panes: [{ id: 1, leafId: LEAF_ID as never }] + }) + + expect(resolvePaneKeyForManager('tab-1', 'tab-1:1', manager)).toMatchObject({ + status: 'unresolved', + reason: 'invalid' + }) + expect(resolvePaneKeyForManager('tab-1', makePaneKey('tab-2', LEAF_ID), manager)).toMatchObject( + { + status: 'unresolved', + reason: 'invalid' + } + ) + }) + + it('reports confirmed-missing when the committed leaf has no live pane', () => { + const manager = makeManager({ numericPaneId: null, panes: [] }) + + expect(resolvePaneKeyForManager('tab-1', PANE_KEY, manager)).toEqual({ + status: 'unresolved', + paneKey: PANE_KEY, + leafId: LEAF_ID, + reason: 'confirmed-missing' + }) + }) + + it('reports ownership-mismatch when the numeric pane handle now belongs to another leaf', () => { + const manager = makeManager({ + numericPaneId: 7, + panes: [{ id: 7, leafId: OTHER_LEAF_ID as never }] + }) + + expect(resolveLeafIdForManager('tab-1', LEAF_ID, manager, PANE_KEY)).toEqual({ + status: 'unresolved', + paneKey: PANE_KEY, + leafId: LEAF_ID, + reason: 'ownership-mismatch' + }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-key-resolution.ts b/src/renderer/src/lib/pane-manager/pane-key-resolution.ts new file mode 100644 index 000000000..a8ebe9094 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-key-resolution.ts @@ -0,0 +1,70 @@ +import { + isTerminalLeafId, + parsePaneKey, + type TerminalLeafId +} from '../../../../shared/stable-pane-id' +import type { ManagedPane } from './pane-manager-types' + +export type PaneKeyUnresolvedReason = 'confirmed-missing' | 'ownership-mismatch' | 'invalid' + +export type PaneKeyResolution = + | { + status: 'resolved' + paneKey: string + leafId: TerminalLeafId + numericPaneId: number + } + | { + status: 'unresolved' + paneKey: string | null + leafId: TerminalLeafId | null + reason: PaneKeyUnresolvedReason + } + +export type PaneKeyResolutionManager = { + getNumericIdForLeaf(leafId: string): number | null + getPanes(): ManagedPane[] +} + +export function resolvePaneKeyForManager( + tabId: string, + paneKey: string, + manager: PaneKeyResolutionManager | null +): PaneKeyResolution { + const parsed = parsePaneKey(paneKey) + if (!parsed || parsed.tabId !== tabId) { + return { status: 'unresolved', paneKey, leafId: parsed?.leafId ?? null, reason: 'invalid' } + } + return resolveLeafIdForManager(tabId, parsed.leafId, manager, paneKey) +} + +export function resolveLeafIdForManager( + tabId: string, + leafId: string, + manager: PaneKeyResolutionManager | null, + paneKey: string | null = null +): PaneKeyResolution { + if (!isTerminalLeafId(leafId)) { + return { status: 'unresolved', paneKey, leafId: null, reason: 'invalid' } + } + if (!manager) { + return { status: 'unresolved', paneKey, leafId, reason: 'confirmed-missing' } + } + + const numericPaneId = manager.getNumericIdForLeaf(leafId) + if (numericPaneId === null) { + return { status: 'unresolved', paneKey, leafId, reason: 'confirmed-missing' } + } + + const pane = manager.getPanes().find((candidate) => candidate.id === numericPaneId) + if (!pane) { + return { status: 'unresolved', paneKey, leafId, reason: 'confirmed-missing' } + } + if (pane.leafId !== leafId) { + // Why: numeric pane ids can be reused after replay/teardown. The stable + // leaf must still match at the moment the caller needs a live handle. + return { status: 'unresolved', paneKey, leafId, reason: 'ownership-mismatch' } + } + + return { status: 'resolved', paneKey: paneKey ?? `${tabId}:${leafId}`, leafId, numericPaneId } +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index ec9fcf177..aff38ce3e 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -26,8 +26,11 @@ vi.mock('@xterm/addon-webgl', () => ({ })) function createPane(): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never return { id: 1, + leafId, + stablePaneId: leafId, terminal: { loadAddon: vi.fn(), refresh: vi.fn(), @@ -271,8 +274,11 @@ describe('openTerminal — Unicode 11 ordering', () => { buffer: { active: { cursorX: 0, cursorY: 0 } } } as unknown as ManagedPaneInternal['terminal'] + const leafId = '22222222-2222-4222-8222-222222222222' as never const pane: ManagedPaneInternal = { id: 1, + leafId, + stablePaneId: leafId, terminal, container: fakeContainer, xtermContainer: fakeContainer, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index f9673b3fa..3f6eab46f 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -13,6 +13,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { SerializeAddon } from '@xterm/addon-serialize' import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import type { DragReorderState } from './pane-drag-reorder' import type { DragReorderCallbacks } from './pane-drag-reorder' import { attachPaneDrag } from './pane-drag-reorder' @@ -36,6 +37,7 @@ function getTerminalUrlOpenHint(): string { export function createPaneDOM( id: number, + leafId: TerminalLeafId, options: PaneManagerOptions, dragState: DragReorderState, dragCallbacks: DragReorderCallbacks, @@ -46,6 +48,7 @@ export function createPaneDOM( const container = document.createElement('div') container.className = 'pane' container.dataset.paneId = String(id) + container.dataset.leafId = leafId // Create .xterm-container — baseline layout (position, width, height, margin) // is CSS-driven (see main.css .xterm-container) so that the data-has-title @@ -102,6 +105,8 @@ export function createPaneDOM( const pane: ManagedPaneInternal = { id, + leafId, + stablePaneId: leafId, terminal, container, xtermContainer, diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 08d1661f1..f6c02c9e9 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -8,6 +8,7 @@ import type { WebLinksAddon } from '@xterm/addon-web-links' import type { WebglAddon } from '@xterm/addon-webgl' import type { SerializeAddon } from '@xterm/addon-serialize' import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' // --------------------------------------------------------------------------- // Public interfaces @@ -21,9 +22,14 @@ export type PaneSpawnHints = { cwd?: string } +export type ClosedPaneInfo = { + paneId: number + leafId: TerminalLeafId +} + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise - onPaneClosed?: (paneId: number) => void + onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void onActivePaneChange?: (pane: ManagedPane) => void onLayoutChanged?: () => void terminalOptions?: (paneId: number) => Partial @@ -54,6 +60,11 @@ export type PaneStyleOptions = { export type ManagedPane = { id: number + /** Durable terminal layout leaf UUID. Use this for paneKey/ORCA_PANE_KEY and + * persisted leaf-keyed state; `id` is only the live renderer handle. */ + leafId: TerminalLeafId + /** Compatibility alias while callers migrate from the older stablePaneId name. */ + stablePaneId: TerminalLeafId terminal: Terminal container: HTMLElement // the .pane element linkTooltip: HTMLElement diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 8e1526f79..5ead38cc9 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -11,35 +11,21 @@ import { applyPaneOpacity, applyRootBackground } from './pane-divider' -import { - createDragReorderState, - hideDropOverlay, - handlePaneDrop, - updateMultiPaneState -} from './pane-drag-reorder' +import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder' import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle' -import { disposeWebgl } from './pane-webgl-renderer' import { shouldFollowMouseFocus } from './focus-follows-mouse' -import { - findPaneChildren, - removeDividers, - promoteSibling, - wrapInSplit, - safeFit, - fitAllPanesInternal, - captureScrollState, - refitPanesUnder -} from './pane-tree-ops' -import { scheduleSplitScrollRestore } from './pane-split-scroll' +import { safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops' import { toPublicPane } from './pane-public-view' import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' -import { reattachWebglIfNeeded } from './pane-webgl-reattach' import { markPaneComplexScriptOutput, resumePaneRendering, setPaneGpuRenderingState, suspendPaneRendering } from './pane-rendering-control' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import { PaneIdentityRegistry } from './pane-identity-registry' +import { closeManagedPane, splitManagedPane } from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } @@ -53,6 +39,7 @@ export class PaneManager { private styleOptions: PaneStyleOptions = {} private destroyed = false private renderingSuspended: boolean + private identities = new PaneIdentityRegistry() // Drag-to-reorder state private dragState = createDragReorderState() @@ -63,8 +50,8 @@ export class PaneManager { this.renderingSuspended = options.initialRenderingSuspended === true } - createInitialPane(opts?: { focus?: boolean }): ManagedPane { - const pane = this.createPaneInternal() + createInitialPane(opts?: { focus?: boolean; leafId?: string }): ManagedPane { + const pane = this.createPaneInternal(opts?.leafId) Object.assign(pane.container.style, { width: '100%', height: '100%', @@ -80,99 +67,48 @@ export class PaneManager { pane.terminal.focus() } - void this.options.onPaneCreated?.(toPublicPane(pane)) + this.publishPaneCreated(pane) return toPublicPane(pane) } splitPane( paneId: number, direction: 'vertical' | 'horizontal', - opts?: { ratio?: number; cwd?: string } + opts?: { ratio?: number; cwd?: string; leafId?: string } ): ManagedPane | null { - const existing = this.panes.get(paneId) - if (!existing) { - return null - } - const newPane = this.createPaneInternal() - const parent = existing.container.parentElement - if (!parent) { - return null - } - - const isVertical = direction === 'vertical' - const divider = this.createDividerWrapped(isVertical) - - // Why: wrapInSplit reparents the existing container, resetting scrollTop. - const scrollState = captureScrollState(existing.terminal) - // Why: lock prevents safeFit/fitAllPanes from restoring scroll during - // the async settle window — scheduleSplitScrollRestore owns the restore. - existing.pendingSplitScrollState = scrollState - - // Why: DOM reparenting can silently invalidate a WebGL context without - // firing contextlost — Chromium reclaims the oldest context near its - // ~8–16 limit. Dispose before the move, reattach in the 200ms timer. - const hadWebgl = !!existing.webglAddon - disposeWebgl(existing) - - wrapInSplit(existing.container, newPane.container, isVertical, divider, opts) - - openTerminal(newPane) - this.activePaneId = newPane.id - applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) - applyDividerStyles(this.root, this.styleOptions) - newPane.terminal?.focus() - updateMultiPaneState(this.getDragCallbacks()) - // Why: forward cwd hint so the new PTY spawns in the source pane's cwd. - void this.options.onPaneCreated?.( - toPublicPane(newPane), - opts?.cwd ? { cwd: opts.cwd } : undefined - ) - this.options.onLayoutChanged?.() - - const reattach = hadWebgl ? reattachWebglIfNeeded : undefined - scheduleSplitScrollRestore( - (id) => this.panes.get(id), - existing.id, - scrollState, - () => this.destroyed, - reattach - ) - - return toPublicPane(newPane) + return splitManagedPane({ + paneId, + direction, + opts, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint), + createDivider: (isVertical) => this.createDividerWrapped(isVertical), + publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints), + getDragCallbacks: () => this.getDragCallbacks(), + setActivePaneId: (id) => { + this.activePaneId = id + }, + isDestroyed: () => this.destroyed + }) } closePane(paneId: number): void { - const pane = this.panes.get(paneId) - if (!pane) { - return - } - const paneContainer = pane.container - const parent = paneContainer.parentElement - if (!parent) { - return - } - disposePane(pane, this.panes) - if (parent.classList.contains('pane-split')) { - const siblings = findPaneChildren(parent) - const sibling = siblings.find((c) => c !== paneContainer) ?? null - paneContainer.remove() - removeDividers(parent) - promoteSibling(sibling, parent, this.root) - } else { - paneContainer.remove() - } - if (this.activePaneId === paneId) { - const next = this.panes.values().next().value as ManagedPaneInternal | undefined - this.activePaneId = next?.id ?? null - next?.terminal.focus() - } - applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) - for (const p of this.panes.values()) { - safeFit(p) - } - updateMultiPaneState(this.getDragCallbacks()) - this.options.onPaneClosed?.(paneId) - this.options.onLayoutChanged?.() + closeManagedPane({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) } getPanes(): ManagedPane[] { @@ -191,6 +127,26 @@ export class PaneManager { return pane ? toPublicPane(pane) : null } + getLeafId(numericPaneId: number): TerminalLeafId | null { + return this.identities.getLeafId(numericPaneId) + } + + getNumericIdForLeaf(leafId: string): number | null { + return this.identities.getNumericIdForLeaf(leafId) + } + + getLeafIdMap(): Map { + return this.identities.getLeafIdMap() + } + + adoptLeafId(numericPaneId: number, leafId: string): boolean { + const pane = this.panes.get(numericPaneId) + if (!pane) { + return false + } + return this.identities.adoptPaneLeafId(numericPaneId, pane, leafId) + } + setActivePane(paneId: number, opts?: { focus?: boolean }): void { const pane = this.panes.get(paneId) if (!pane) { @@ -256,14 +212,17 @@ export class PaneManager { for (const pane of this.panes.values()) { disposePane(pane, this.panes) } + this.identities.clear() this.root.innerHTML = '' this.activePaneId = null } - private createPaneInternal(): ManagedPaneInternal { + private createPaneInternal(leafIdHint?: string): ManagedPaneInternal { const id = this.nextPaneId++ + const leafId = this.identities.claimLeafId(leafIdHint) const pane = createPaneDOM( id, + leafId, this.options, this.dragState, this.getDragCallbacks(), @@ -280,9 +239,20 @@ export class PaneManager { ) pane.webglAttachmentDeferred = this.renderingSuspended this.panes.set(id, pane) + this.identities.register(id, leafId) return pane } + private publishPaneCreated( + pane: ManagedPaneInternal, + spawnHints?: Parameters>[1] + ): void { + // Why: onPaneCreated wires PTY/status identity synchronously. After this + // point, replacing the leaf id would fork ORCA_PANE_KEY from layout state. + this.identities.markPublished(pane.id) + void this.options.onPaneCreated?.(toPublicPane(pane), spawnHints) + } + private handlePaneMouseEnter(paneId: number, event: MouseEvent): void { if ( shouldFollowMouseFocus({ diff --git a/src/renderer/src/lib/pane-manager/pane-public-view.ts b/src/renderer/src/lib/pane-manager/pane-public-view.ts index c0ae326b3..578f69a49 100644 --- a/src/renderer/src/lib/pane-manager/pane-public-view.ts +++ b/src/renderer/src/lib/pane-manager/pane-public-view.ts @@ -3,6 +3,8 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' export function toPublicPane(pane: ManagedPaneInternal): ManagedPane { return { id: pane.id, + leafId: pane.leafId, + stablePaneId: pane.stablePaneId, terminal: pane.terminal, container: pane.container, linkTooltip: pane.linkTooltip, diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts new file mode 100644 index 000000000..52a20f149 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -0,0 +1,154 @@ +import type { + ManagedPane, + ManagedPaneInternal, + PaneManagerOptions, + PaneStyleOptions +} from './pane-manager-types' +import type { DragReorderCallbacks } from './pane-drag-reorder' +import { updateMultiPaneState } from './pane-drag-reorder' +import { + captureScrollState, + findPaneChildren, + promoteSibling, + removeDividers, + safeFit, + wrapInSplit +} from './pane-tree-ops' +import { applyDividerStyles, applyPaneOpacity } from './pane-divider' +import { disposePane, openTerminal } from './pane-lifecycle' +import { disposeWebgl } from './pane-webgl-renderer' +import { scheduleSplitScrollRestore } from './pane-split-scroll' +import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { toPublicPane } from './pane-public-view' + +type SplitManagedPaneArgs = { + paneId: number + direction: 'vertical' | 'horizontal' + opts?: { ratio?: number; cwd?: string; leafId?: string } + panes: Map + root: HTMLElement + styleOptions: PaneStyleOptions + managerOptions: PaneManagerOptions + createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal + createDivider: (isVertical: boolean) => HTMLElement + publishPaneCreated: ( + pane: ManagedPaneInternal, + spawnHints?: Parameters>[1] + ) => void + getDragCallbacks: () => DragReorderCallbacks + setActivePaneId: (paneId: number | null) => void + isDestroyed: () => boolean +} + +export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null { + const existing = args.panes.get(args.paneId) + if (!existing) { + return null + } + const parent = existing.container.parentElement + if (!parent) { + return null + } + const newPane = args.createPaneInternal(args.opts?.leafId) + const isVertical = args.direction === 'vertical' + const divider = args.createDivider(isVertical) + + // Why: wrapInSplit reparents the existing container, resetting scrollTop. + const scrollState = captureScrollState(existing.terminal) + // Why: lock prevents safeFit/fitAllPanes from restoring scroll during the + // async settle window; scheduleSplitScrollRestore owns the restore. + existing.pendingSplitScrollState = scrollState + + // Why: DOM reparenting can silently invalidate a WebGL context without + // firing contextlost, so dispose before the move and reattach after settle. + const hadWebgl = !!existing.webglAddon + disposeWebgl(existing) + + wrapInSplit(existing.container, newPane.container, isVertical, divider, args.opts) + args.setActivePaneId(newPane.id) + openSplitPane(args, newPane, args.opts?.cwd) + + scheduleSplitScrollRestore( + (id) => args.panes.get(id), + existing.id, + scrollState, + args.isDestroyed, + hadWebgl ? reattachWebglIfNeeded : undefined + ) + + return toPublicPane(newPane) +} + +function openSplitPane( + args: SplitManagedPaneArgs, + newPane: ManagedPaneInternal, + cwd?: string +): void { + openTerminal(newPane) + applyPaneOpacity(args.panes.values(), newPane.id, args.styleOptions) + applyDividerStyles(args.root, args.styleOptions) + newPane.terminal.focus() + updateMultiPaneState(args.getDragCallbacks()) + // Why: forward cwd hint so the new PTY spawns in the source pane's cwd. + args.publishPaneCreated(newPane, cwd ? { cwd } : undefined) + args.managerOptions.onLayoutChanged?.() +} + +type CloseManagedPaneArgs = { + paneId: number + activePaneId: number | null + panes: Map + root: HTMLElement + styleOptions: PaneStyleOptions + managerOptions: PaneManagerOptions + getDragCallbacks: () => DragReorderCallbacks + releasePaneIdentity: (numericPaneId: number) => void + setActivePaneId: (paneId: number | null) => void +} + +export function closeManagedPane(args: CloseManagedPaneArgs): void { + const pane = args.panes.get(args.paneId) + if (!pane) { + return + } + const closedLeafId = pane.leafId + args.releasePaneIdentity(args.paneId) + removePaneContainer(args, pane) + const nextActivePaneId = activateReplacementPane(args) + applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) + for (const p of args.panes.values()) { + safeFit(p) + } + updateMultiPaneState(args.getDragCallbacks()) + args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onLayoutChanged?.() +} + +function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { + const paneContainer = pane.container + const parent = paneContainer.parentElement + disposePane(pane, args.panes) + if (!parent) { + return + } + if (parent.classList.contains('pane-split')) { + const siblings = findPaneChildren(parent) + const sibling = siblings.find((c) => c !== paneContainer) ?? null + paneContainer.remove() + removeDividers(parent) + promoteSibling(sibling, parent, args.root) + } else { + paneContainer.remove() + } +} + +function activateReplacementPane(args: CloseManagedPaneArgs): number | null { + if (args.activePaneId !== args.paneId) { + return args.activePaneId + } + const next = args.panes.values().next().value as ManagedPaneInternal | undefined + const nextActivePaneId = next?.id ?? null + args.setActivePaneId(nextActivePaneId) + next?.terminal.focus() + return nextActivePaneId +} diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts index 4b059bb29..4ff8c5eb8 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' const restoreScrollState = vi.hoisted(() => vi.fn()) @@ -22,6 +23,8 @@ const alternateScrollState = { bufferType: 'alternate' } satisfies ScrollState +const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + function createPane(bufferType: 'normal' | 'alternate'): { pane: ManagedPaneInternal bufferChangeDisposable: { dispose: ReturnType } @@ -31,6 +34,8 @@ function createPane(bufferType: 'normal' | 'alternate'): { const bufferChangeDisposable = { dispose: vi.fn() } const pane: ManagedPaneInternal = { id: 1, + leafId: TEST_LEAF_ID, + stablePaneId: TEST_LEAF_ID, terminal: { rows: 24, refresh: vi.fn(), diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts index 509de8157..c42b6d2af 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts @@ -3,8 +3,11 @@ import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-typ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' function createPane(): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never return { id: 1, + leafId, + stablePaneId: leafId, terminal: { cols: 80, rows: 24 diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index f0c78cce2..b163d4934 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -20,6 +20,7 @@ function createPane({ terminalRows: number paneId?: number }): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never const fit = vi.fn() const proposeDimensions = vi.fn(() => ({ cols: proposedCols, rows: proposedRows })) const terminal = { @@ -44,6 +45,8 @@ function createPane({ return { id: paneId, + leafId, + stablePaneId: leafId, terminal: terminal as never, container: { dataset: {} } as never, xtermContainer: {} as never, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 47e83281e..83cb03e2b 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1,12 +1,12 @@ /* eslint-disable max-lines -- Why: runtime graph sync and mobile session-tab publication share the same injected renderer state and terminal registry. Keeping them together prevents a second store/registry reader from drifting. */ import { collectLeafIdsInOrder, - paneLeafId, serializePaneTree } from '@/components/terminal-pane/layout-serialization' import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { createBrowserUuid } from '@/lib/browser-uuid' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' import type { AppState } from '@/store/types' import type { RuntimeMobileSessionFileTab, @@ -15,6 +15,7 @@ import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { getActiveTabNavOrder } from '../components/tab-bar/group-tab-order' type RegisteredTerminalTab = { @@ -85,11 +86,11 @@ export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | nul manager.getActivePane()?.terminal.focus() return true } - const pane = manager.getPanes().find((candidate) => paneLeafId(candidate.id) === leafId) - if (!pane) { + const resolution = resolveLeafIdForManager(tabId, leafId, manager) + if (resolution.status !== 'resolved') { return false } - manager.setActivePane(pane.id, { focus: true }) + manager.setActivePane(resolution.numericPaneId, { focus: true }) scheduleRuntimeGraphSync() return true } @@ -293,13 +294,13 @@ async function syncRuntimeGraph(): Promise { tabId, worktreeId: registeredTab.worktreeId, title: tab.customTitle ?? tab.title, - activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId), + activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null), layout: serializePaneTree(root) }) const savedPtyIdsByLeafId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} for (const pane of manager?.getPanes() ?? []) { - const leafId = paneLeafId(pane.id) + const leafId = pane.leafId const ptyId = registeredTab.getPtyIdForPane(pane.id) const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null const registeredTime = tabRegisteredAt.get(tabId) ?? 0 @@ -452,21 +453,21 @@ function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] { const registered = registeredTabs.get(tabId) const manager = registered?.getManager() - const liveLeafIds = manager?.getPanes().map((pane) => paneLeafId(pane.id)) ?? [] + const liveLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] if (liveLeafIds.length > 0) { return liveLeafIds } const layout = state.terminalLayoutsByTabId[tabId] - const persistedLeafIds = collectLeafIdsInOrder(layout?.root) + const persistedLeafIds = collectLeafIdsInOrder(layout?.root).filter(isTerminalLeafId) if (persistedLeafIds.length > 0) { return persistedLeafIds } // Why: a newly-created terminal tab can be in the store before TerminalPane - // mounts. Publish its deterministic first-pane surface so mobile does not - // fill the startup gap from terminal.list. - return [paneLeafId(1)] + // mounts. Without a live or persisted UUID leaf, there is no stable mobile + // surface to publish yet; fabricating pane:1 would become stale after mount. + return [] } function buildMobileTerminalSurfaceTabs( @@ -482,16 +483,23 @@ function buildMobileTerminalSurfaceTabs( group.activeTabId === unifiedTabId ) === true : state.activeTabId === terminal.id - const liveActiveLeafId = - registeredTabs.get(terminal.id)?.getManager()?.getActivePane()?.id ?? null + const manager = registeredTabs.get(terminal.id)?.getManager() + const liveActivePaneId = manager?.getActivePane()?.id ?? null + const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, state) const activeLeafId = - liveActiveLeafId !== null - ? paneLeafId(liveActiveLeafId) - : (state.terminalLayoutsByTabId[terminal.id]?.activeLeafId ?? paneLeafId(1)) + liveActivePaneId !== null + ? (manager?.getLeafId(liveActivePaneId) ?? null) + : (state.terminalLayoutsByTabId[terminal.id]?.activeLeafId ?? leafIds[0] ?? null) const paneTitles = state.runtimePaneTitlesByTabId[terminal.id] ?? {} - return getRuntimeLeafIdsForTerminal(terminal.id, state).map((leafId) => { - const paneId = /^pane:(\d+)$/.exec(leafId)?.[1] - const paneTitle = paneId ? paneTitles[Number(paneId)] : undefined + return leafIds.map((leafId) => { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null + const paneTitle = + numericPaneId !== null + ? paneTitles[numericPaneId] + : legacyPaneId + ? paneTitles[Number(legacyPaneId)] + : undefined return { type: 'terminal' as const, id: mobileTerminalSurfaceId(terminal.id, leafId), diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 361db4a40..d61aa81cc 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -7,6 +7,7 @@ import { type AgentStateHistoryEntry, type AgentStatusEntry, type AgentType, + type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' @@ -30,9 +31,12 @@ export type RetainedAgentEntry = { } export type AgentStatusSlice = { - /** Explicit agent status entries keyed by `${tabId}:${paneId}` composite. + /** Explicit agent status entries keyed by `${tabId}:${leafId}` composite. * Real-time only — lives in renderer memory, not persisted to disk. */ agentStatusByPaneKey: Record + /** PTYs that still report legacy numeric pane keys but have registry-backed + * UUID pane proof. Stored separately from normal hook-reported status. */ + migrationUnsupportedByPtyId: Record /** Monotonic tick that advances when agent-status freshness boundaries pass. */ agentStatusEpoch: number @@ -55,6 +59,9 @@ export type AgentStatusSlice = { timing?: { updatedAt?: number; stateStartedAt?: number } ) => void + setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void + clearMigrationUnsupportedPty: (ptyId: string) => void + /** Remove a single entry (e.g., when a pane's terminal exits). */ removeAgentStatus: (paneKey: string) => void @@ -116,6 +123,22 @@ function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boo return false } +function pruneMigrationUnsupportedEntries( + entries: Record, + predicate: (entry: MigrationUnsupportedPtyEntry) => boolean +): { next: Record; changed: boolean } { + let changed = false + const next: Record = {} + for (const [ptyId, entry] of Object.entries(entries)) { + if (predicate(entry)) { + changed = true + continue + } + next[ptyId] = entry + } + return { next: changed ? next : entries, changed } +} + export const createAgentStatusSlice: StateCreator = ( set, get @@ -142,6 +165,7 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey + ) return { agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry }, + migrationUnsupportedByPtyId: migrationUnsupported.next, retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - agentStatusEpoch: sortRelevantChange ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: sortRelevantChange ? s.sortEpoch + 1 : s.sortEpoch + agentStatusEpoch: + sortRelevantChange || migrationUnsupported.changed + ? s.agentStatusEpoch + 1 + : s.agentStatusEpoch, + sortEpoch: + sortRelevantChange || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch } }) // Why: schedule after set completes so the timer reads the updated map. @@ -277,13 +310,55 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) }, - removeAgentStatus: (paneKey) => { - if (!(paneKey in get().agentStatusByPaneKey)) { + setMigrationUnsupportedPty: (entry) => { + set((s) => { + const existing = s.migrationUnsupportedByPtyId[entry.ptyId] + if (existing && entry.updatedAt < existing.updatedAt) { + return s + } + return { + migrationUnsupportedByPtyId: { + ...s.migrationUnsupportedByPtyId, + [entry.ptyId]: entry + }, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + clearMigrationUnsupportedPty: (ptyId) => { + if (!(ptyId in get().migrationUnsupportedByPtyId)) { return } set((s) => { - const next = { ...s.agentStatusByPaneKey } - delete next[paneKey] + const next = { ...s.migrationUnsupportedByPtyId } + delete next[ptyId] + return { + migrationUnsupportedByPtyId: next, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + removeAgentStatus: (paneKey) => { + if ( + !(paneKey in get().agentStatusByPaneKey) && + !Object.values(get().migrationUnsupportedByPtyId).some((entry) => entry.paneKey === paneKey) + ) { + return + } + set((s) => { + const hasLive = paneKey in s.agentStatusByPaneKey + const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete next[paneKey] + } + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) // Why: acknowledgedAgentsByPaneKey is written per user-ack but owned // lifecycle-wise by the pane — drop the ack entry in lockstep with the // live-map entry so closed panes don't leave stale ack timestamps that @@ -299,6 +374,7 @@ export const createAgentStatusSlice: StateCreator k.startsWith(prefix)) - if (toRemove.length === 0) { + const hasMigrationUnsupported = Object.values(get().migrationUnsupportedByPtyId).some( + (entry) => entry.paneKey?.startsWith(prefix) + ) + if (toRemove.length === 0 && !hasMigrationUnsupported) { return } set((s) => { @@ -321,6 +400,10 @@ export const createAgentStatusSlice: StateCreator entry.paneKey?.startsWith(prefix) ?? false + ) // See removeAgentStatus for rationale on ack cleanup. let nextAck = s.acknowledgedAgentsByPaneKey const ackKeys = Object.keys(nextAck).filter((k) => k.startsWith(prefix)) @@ -336,6 +419,7 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey + ) // See removeAgentStatus for rationale on ack cleanup. Apply this // regardless of live/retained presence — the ack entry is owned by // the pane lifecycle independently of live/retained state. @@ -371,7 +459,7 @@ export const createAgentStatusSlice: StateCreator k.startsWith(prefix) ) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) // See removeAgentStatus for rationale on ack cleanup. Apply this // regardless of live/retained presence — ack entries are owned by // the pane lifecycle independently of live/retained state. @@ -481,7 +575,7 @@ export const createAgentStatusSlice: StateCreator paneKey) const retainedKeySet = new Set(retainedKeys) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => + entry.worktreeId === worktreeId || + (entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false) + ) // See removeAgentStatus for rationale on ack cleanup. Current tabs are // swept by prefix; orphan retained rows are swept by their retained key. let nextAck = s.acknowledgedAgentsByPaneKey @@ -573,7 +675,7 @@ export const createAgentStatusSlice: StateCreator { }) }) -// Why: CLI-spawned background terminals stamp ORCA_PANE_KEY=`${tabId}:1` into -// the PTY env at spawn time. The renderer must adopt the tab under the same -// id so hook events route to the correct slot. See -// docs/cli-terminal-hook-pane-key.md. +// Why: CLI-spawned background terminals stamp ORCA_PANE_KEY into the PTY env +// at spawn time. The renderer must adopt the tab under the same id so hook +// events route to the correct slot. describe('createTab tabId hint', () => { it('uses the supplied id when no collision exists', () => { const store = createTestStore() diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 2e86742c2..990a4f3fc 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -9,6 +9,7 @@ import type { TerminalTab, Worktree } from '../../../../shared/types' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -1305,12 +1306,15 @@ describe('reconnectPersistedTerminals', () => { // sees the tab as active (green dot) even before the terminal mounts. // connectPanePty reads ptyIdsByLeafId for per-leaf daemon sessions. expect(s.tabsByWorktree[wt1][0].ptyId).toBe('daemon-session-B') - // ptyIdsByLeafId preserved from hydration for connectPanePty to consume + // ptyIdsByLeafId preserved from hydration for connectPanePty to consume, + // but legacy pane:* leaves are reminted to durable UUID leaves at hydration. const layout = s.terminalLayoutsByTabId['tab1'] - expect(layout.ptyIdsByLeafId).toEqual({ - 'pane:1': 'daemon-session-A', - 'pane:3': 'daemon-session-B' - }) + const bindings = layout.ptyIdsByLeafId ?? {} + expect(Object.keys(bindings)).toHaveLength(2) + expect(Object.keys(bindings).every(isTerminalLeafId)).toBe(true) + expect(Object.keys(bindings)).not.toContain('pane:1') + expect(Object.keys(bindings)).not.toContain('pane:3') + expect(Object.values(bindings).sort()).toEqual(['daemon-session-A', 'daemon-session-B']) expect(s.workspaceSessionReady).toBe(true) }) }) diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 10e4595d2..397660556 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -9,6 +9,20 @@ export function emptyLayoutSnapshot(): TerminalLayoutSnapshot { } } +export function singlePaneLayoutSnapshot( + leafId: string, + ptyId?: string, + title?: string | null +): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(ptyId ? { ptyIdsByLeafId: { [leafId]: ptyId } } : {}), + ...(title ? { titlesByLeafId: { [leafId]: title } } : {}) + } +} + export function clearTransientTerminalState(tab: TerminalTab, index: number): TerminalTab { return { ...tab, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 949eb2049..8e0f0aa90 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -27,6 +27,7 @@ import { ensurePtyDispatcher, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -154,8 +155,8 @@ export type TerminalSlice = { pendingActivationSpawn?: boolean initialPtyId?: string activate?: boolean - /** Pre-allocated tab id (e.g. minted by main for CLI-spawned terminals - * whose PTY env already carries `paneKey=`${tabId}:1``). Falls back to + /** Pre-allocated tab id (e.g. minted by main for CLI/runtime-spawned + * terminals whose PTY env already carries a pane key). Falls back to * minting a fresh id when omitted or when the supplied id collides * with an existing tab anywhere in the store (tabIds form the global * paneKey namespace, so collisions are checked across all worktrees). */ @@ -226,7 +227,7 @@ export type TerminalSlice = { tabId: string ) => { command: string; env?: Record } | null /** Per-pane timestamp (ms) when the prompt-cache countdown started (agent became idle). - * Keys are `${tabId}:${paneId}` composites so split-pane tabs can track each pane + * Keys are `${tabId}:${leafId}` composites so split-pane tabs can track each pane * independently. null means no active timer for that pane. */ cacheTimerByKey: Record setCacheTimerStartedAt: (key: string, ts: number | null) => void @@ -283,7 +284,7 @@ export const createTerminalSlice: StateCreator setCacheTimerStartedAt: (key, ts) => { set((s) => { const next = { ...s.cacheTimerByKey, [key]: ts } - // Why: when a real pane transition writes a key like `${tabId}:${paneId}`, + // Why: when a real pane transition writes a key like `${tabId}:${leafId}`, // clean up any `${tabId}:seed` sentinel left by seedCacheTimersForIdleTabs. // This prevents phantom timers when the seeded key doesn't match the real // pane ID (e.g., idle Claude in pane 2 of a split tab). @@ -367,7 +368,7 @@ export const createTerminalSlice: StateCreator // boundary at useIpcEvents.ts spreads `id` whenever `tabId !== undefined`, // so a stray `''` or whitespace-only value from a future producer would // otherwise be persisted as a real tab id and break paneKey routing - // (`${tabId}:1` would shape as `:1` or `:1`). + // (`${tabId}:${leafId}` would inherit the bad tab segment). const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : '' const hintedId = trimmedHint.length > 0 ? trimmedHint : undefined const idCollides = @@ -537,7 +538,7 @@ export const createTerminalSlice: StateCreator const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } delete nextPendingIssueCommandSplitByTabId[tabId] const nextCacheTimer = { ...s.cacheTimerByKey } - // Why: cache timer keys are `${tabId}:${paneId}` composites. Remove all + // Why: cache timer keys are `${tabId}:${leafId}` composites. Remove all // entries for the closing tab, regardless of how many panes it had. for (const key of Object.keys(nextCacheTimer)) { if (key.startsWith(`${tabId}:`)) { @@ -1740,7 +1741,13 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals can reattach each split-pane leaf // to its specific daemon session (not just the tab-level ptyId). terminalLayoutsByTabId: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId)) + Object.entries(session.terminalLayoutsByTabId) + .filter(([tabId]) => validTabIds.has(tabId)) + .map(([tabId, layout]) => { + // Why: old sessions can contain renderer-local pane:1-style leaf + // ids. Normalize during hydration before runtime/mobile surfaces read them. + return [tabId, normalizeTerminalLayoutSnapshot(layout).snapshot] + }) ) } }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2541e1593..378be8f8b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -160,6 +160,9 @@ function createWebPreloadApi(): Partial { agentStatus: { onSet: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), + onMigrationUnsupported: () => noopUnsubscribe, + onMigrationUnsupportedClear: () => noopUnsubscribe, + getMigrationUnsupportedSnapshot: () => Promise.resolve([]), drop: () => {} }, mobile: { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index e0fa65dc7..fa3c0e8ac 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -12,6 +12,10 @@ import { writeEndpointFile, type HookListenerState } from './agent-hook-listener' +import { makePaneKey } from './stable-pane-id' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) describe('shared agent-hook-listener', () => { let state: HookListenerState @@ -47,7 +51,7 @@ describe('shared agent-hook-listener', () => { state, 'claude', { - paneKey: 'tab-1:0', + paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt', env: 'production', @@ -57,7 +61,7 @@ describe('shared agent-hook-listener', () => { 'production' ) expect(event).not.toBeNull() - expect(event!.paneKey).toBe('tab-1:0') + expect(event!.paneKey).toBe(PANE_KEY) expect(event!.connectionId).toBeNull() expect(event!.payload.state).toBe('working') expect(event!.payload.prompt).toBe('hello') @@ -69,7 +73,7 @@ describe('shared agent-hook-listener', () => { state, 'claude', { - paneKey: 'tab-1:0', + paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' } }, 'production' @@ -97,7 +101,7 @@ describe('shared agent-hook-listener', () => { normalizeHookPayload( a, 'claude', - { paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } }, + { paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } }, 'production' ) // The second listener has no cached prompt for this paneKey, so a tool @@ -106,7 +110,7 @@ describe('shared agent-hook-listener', () => { b, 'claude', { - paneKey: 'p', + paneKey: PANE_KEY, payload: { hook_event_name: 'PreToolUse', tool_name: 'Read', diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f2887b308..3b5e2c36b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -29,6 +29,7 @@ import { join } from 'path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' +import { parsePaneKey } from './stable-pane-id' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -41,7 +42,7 @@ const MAX_WARNED_KEYS = 32 /** Slowloris cap: drop requests that have not finished sending after 5 s. */ export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 -/** Bound paneKey size — `${tabId}:${paneId}` is well under 200 chars in +/** Bound paneKey size — `${tabId}:${leafUuid}` is well under 200 chars in * practice; cap defends per-pane caches against pathological input. * Exported so non-HTTP ingest paths (e.g. Orca's `ingestRemote`) can apply * the same cap as defense-in-depth. */ @@ -1161,6 +1162,7 @@ export function normalizeHookPayload( const record = body as Record const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const parsedPaneKey = parsePaneKey(paneKey) const rawPayload = record.payload const hookPayload = typeof rawPayload === 'string' @@ -1175,6 +1177,7 @@ export function normalizeHookPayload( if ( !paneKey || paneKey.length > MAX_PANE_KEY_LEN || + !parsedPaneKey || typeof hookPayload !== 'object' || hookPayload === null ) { @@ -1188,6 +1191,9 @@ export function normalizeHookPayload( }) const tabId = readStringField(record, 'tabId') + if (tabId && tabId !== parsedPaneKey.tabId) { + return null + } const worktreeId = readStringField(record, 'worktreeId') const eventName = (hookPayload as Record).hook_event_name diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 70fedb0cc..402907d33 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -61,7 +61,7 @@ export type AgentStatusEntry = { * (tool/prompt pings reset updatedAt but not stateStartedAt). */ stateStartedAt: number agentType?: AgentType - /** Composite key: `${tabId}:${paneId}` — matches the cacheTimerByKey convention. */ + /** Composite key: `${tabId}:${leafId}` where leafId is a stable UUID layout leaf. */ paneKey: string terminalTitle?: string /** Rolling log of previous states. Each entry records a state the agent was in @@ -81,6 +81,18 @@ export type AgentStatusEntry = { interrupted?: boolean } +export type MigrationUnsupportedPtyEntry = { + ptyId: string + worktreeId?: string + tabId?: string + leafId?: string + /** Registry-backed UUID pane proof, when available. */ + paneKey?: string + reason: 'legacy-numeric-pane-key' + source: 'local' | 'ssh' + updatedAt: number +} + // ─── Agent status payload shape (what hook receivers send via IPC) ────────── // Hook integrations only need to provide normalized state fields. The // remaining AgentStatusEntry fields (updatedAt, paneKey, etc.) are populated diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9e602188d..f643e0b7e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -317,6 +317,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), sshTargets: [], sshRemotePtyLeases: [], + migrationUnsupportedPtyEntries: [], automations: [], automationRuns: [], onboarding: getDefaultOnboardingState() diff --git a/src/shared/pane-key.ts b/src/shared/pane-key.ts index c0bcc21b8..ce7d10065 100644 --- a/src/shared/pane-key.ts +++ b/src/shared/pane-key.ts @@ -1,6 +1,4 @@ -/** Why: CLI-spawned terminals bake `paneKey = `${tabId}:${FIRST_PANE_ID}`` into - * the PTY env at spawn time, before any pane has actually been allocated. - * This constant must match the renderer's PaneManager.nextPaneId initial - * value so hook events route to the first pane. See - * docs/cli-terminal-hook-pane-key.md. */ +/** Why: live PaneManager ids are still 1-based numeric handles even though + * durable pane keys use UUID leaf ids. Keep the first numeric id centralized + * for runtime-title mapping and legacy migration fallbacks. */ export const FIRST_PANE_ID = 1 diff --git a/src/shared/stable-pane-id.test.ts b/src/shared/stable-pane-id.test.ts new file mode 100644 index 000000000..a380cfacb --- /dev/null +++ b/src/shared/stable-pane-id.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { isStablePaneId, isTerminalLeafId, makePaneKey, parsePaneKey } from './stable-pane-id' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +describe('stable pane ids', () => { + it('recognizes UUID leaf ids as stable pane ids', () => { + expect(isStablePaneId(LEAF_ID)).toBe(true) + expect(isTerminalLeafId(LEAF_ID)).toBe(true) + }) + + it('rejects legacy numeric pane ids and malformed UUIDs', () => { + for (const value of ['1', 'pane:1', '11111111-1111-6111-8111-111111111111', '']) { + expect(isStablePaneId(value)).toBe(false) + expect(isTerminalLeafId(value)).toBe(false) + } + }) + + it('builds and parses pane keys using the tab id and UUID leaf id', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + + expect(paneKey).toBe(`tab-1:${LEAF_ID}`) + expect(parsePaneKey(paneKey)).toEqual({ + tabId: 'tab-1', + leafId: LEAF_ID, + stablePaneId: LEAF_ID + }) + }) + + it('rejects ambiguous tab ids and non-UUID leaf ids when building keys', () => { + expect(() => makePaneKey('', LEAF_ID)).toThrow(/tabId/) + expect(() => makePaneKey('tab:1', LEAF_ID)).toThrow(/tabId/) + expect(() => makePaneKey('tab-1', '1')).toThrow(/UUID/) + }) + + it('rejects ambiguous or legacy pane-key inputs when parsing', () => { + expect(parsePaneKey('tab-1:1')).toBeNull() + expect(parsePaneKey(`tab:1:${LEAF_ID}`)).toBeNull() + expect(parsePaneKey(`:${LEAF_ID}`)).toBeNull() + expect(parsePaneKey('tab-1:')).toBeNull() + }) +}) diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts new file mode 100644 index 000000000..75da3a6a2 --- /dev/null +++ b/src/shared/stable-pane-id.ts @@ -0,0 +1,45 @@ +// Why: paneKey crosses renderer reloads, PTY env, hook IPC, and retained UI +// rows, so it must use the durable terminal-layout leaf UUID instead of the +// renderer-local numeric PaneManager id. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +declare const stablePaneIdBrand: unique symbol +declare const terminalLeafIdBrand: unique symbol +declare const paneKeyBrand: unique symbol + +export type StablePaneId = string & { readonly [stablePaneIdBrand]: true } +export type TerminalLeafId = StablePaneId & { readonly [terminalLeafIdBrand]: true } +export type PaneKey = string & { readonly [paneKeyBrand]: true } + +export function isStablePaneId(value: string): value is StablePaneId { + return UUID_RE.test(value) +} + +export function isTerminalLeafId(value: string): value is TerminalLeafId { + return isStablePaneId(value) +} + +export function makePaneKey(tabId: string, stableLeafId: string): PaneKey { + if (!tabId || tabId.includes(':')) { + throw new Error('tabId must be non-empty and must not contain ":"') + } + if (!isTerminalLeafId(stableLeafId)) { + throw new Error('stableLeafId must be a UUID') + } + return `${tabId}:${stableLeafId}` as PaneKey +} + +export function parsePaneKey( + paneKey: string +): { tabId: string; leafId: TerminalLeafId; stablePaneId: StablePaneId } | null { + const first = paneKey.indexOf(':') + if (first <= 0 || first !== paneKey.lastIndexOf(':') || first === paneKey.length - 1) { + return null + } + const tabId = paneKey.slice(0, first) + const leafId = paneKey.slice(first + 1) + if (!isTerminalLeafId(leafId)) { + return null + } + return { tabId, leafId, stablePaneId: leafId } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d2441f4e7..04eb6588d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,6 +3,7 @@ import type { SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationRun } from './automations-types' import type { WorkspaceSource } from './telemetry-events' import type { GitHubProjectSettings } from './github-project-types' +import type { MigrationUnsupportedPtyEntry } from './agent-status-types' import type { VoiceSettings } from './speech-types' import type { GitLabProjectSettings } from './gitlab-types' @@ -426,7 +427,7 @@ export type TerminalLayoutSnapshot = { ptyIdsByLeafId?: Record /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ buffersByLeafId?: Record - /** User-assigned pane titles, keyed by leafId (e.g. "pane:3"). + /** User-assigned pane titles, keyed by stable layout leaf UUID. * Persisted alongside buffers via the existing session:set flow. */ titlesByLeafId?: Record } @@ -1801,6 +1802,7 @@ export type PersistedState = { workspaceSession: WorkspaceSessionState sshTargets: SshTarget[] sshRemotePtyLeases: SshRemotePtyLease[] + migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] automations: Automation[] automationRuns: AutomationRun[] onboarding: OnboardingState diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2fd4eb3fa..97d40e72a 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -20,6 +20,10 @@ import { normalizeBrowserHistoryEntries } from './workspace-session-browser-hist // ─── Terminal pane layout (recursive) ─────────────────────────────── const terminalPaneSplitDirectionSchema = z.enum(['vertical', 'horizontal']) +const terminalTabIdSchema = z + .string() + .min(1) + .refine((value) => !value.includes(':'), 'terminal tab id must not contain ":"') // Why: z.lazy + type annotation keeps the recursive inference working without // forcing zod to resolve the whole tree at definition time. @@ -51,7 +55,7 @@ const terminalLayoutSnapshotSchema = z.object({ // ─── Terminal tab (legacy) ────────────────────────────────────────── const terminalTabSchema = z.object({ - id: z.string(), + id: terminalTabIdSchema, ptyId: z.string().nullable(), worktreeId: z.string(), title: z.string(), @@ -195,7 +199,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o activeWorktreeId: z.string().nullable(), activeTabId: z.string().nullable(), tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)), - terminalLayoutsByTabId: z.record(z.string(), terminalLayoutSnapshotSchema), + terminalLayoutsByTabId: z.record(terminalTabIdSchema, terminalLayoutSnapshotSchema), activeWorktreeIdsOnShutdown: z.array(z.string()).optional(), openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(), activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), @@ -210,7 +214,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o tabGroupLayouts: z.record(z.string(), tabGroupLayoutNodeSchema).optional(), activeGroupIdByWorktree: z.record(z.string(), z.string()).optional(), activeConnectionIdsAtShutdown: z.array(z.string()).optional(), - remoteSessionIdsByTabId: z.record(z.string(), z.string()).optional(), + remoteSessionIdsByTabId: z.record(terminalTabIdSchema, z.string()).optional(), // Why: the sort comparator in order-empty-query-worktrees.ts would produce // NaN (undefined sort order) if a corrupted session file carried NaN or // Infinity here. Parse leniently: drop individual bad entries rather than diff --git a/tests/e2e/activity-agent-pane-isolation.spec.ts b/tests/e2e/activity-agent-pane-isolation.spec.ts new file mode 100644 index 000000000..6272b9d0d --- /dev/null +++ b/tests/e2e/activity-agent-pane-isolation.spec.ts @@ -0,0 +1,382 @@ +/* eslint-disable max-lines -- Activity E2E keeps the setup helpers beside the split-pane, split-group, and workspace-card routing assertions they support. */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount, + waitForPaneIdentitySnapshot, + type PaneIdentitySnapshot +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +type SeededActivityThread = { + paneKey: string + leafId: string + prompt: string +} + +type ActivityPaneVisibility = { + slotId: string | null + allLeafIds: string[] + visibleLeafIds: string[] +} + +type ActivePaneSelection = { + activeWorktreeId: string | null + activeGroupId: string | null + activeTabId: string | null + activeLeafId: string | null + activePaneId: number | null +} + +type SplitGroupTerminal = { + sourceGroupId: string + groupId: string + tabId: string +} + +async function seedActivityThread( + page: Page, + thread: SeededActivityThread, + title: string, + state: 'blocked' | 'done', + message: string, + startedAt: number +): Promise { + await page.evaluate( + ({ thread, title, state, message, startedAt }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + store.getState().setAgentStatus( + thread.paneKey, + { + state, + prompt: thread.prompt, + agentType: 'codex', + lastAssistantMessage: message + }, + title, + { updatedAt: startedAt, stateStartedAt: startedAt } + ) + }, + { thread, title, state, message, startedAt } + ) +} + +async function seedActivityThreadsForSplitPanes( + page: Page, + snapshot: PaneIdentitySnapshot +): Promise<[SeededActivityThread, SeededActivityThread]> { + const [firstPane, secondPane] = snapshot.panes + if (!firstPane || !secondPane) { + throw new Error('Activity pane isolation test needs two split panes') + } + + const now = Date.now() + const first: SeededActivityThread = { + paneKey: `${snapshot.tabId}:${firstPane.leafId}`, + leafId: firstPane.leafId, + prompt: `ACTIVITY_UUID_LEFT_${now}` + } + const second: SeededActivityThread = { + paneKey: `${snapshot.tabId}:${secondPane.leafId}`, + leafId: secondPane.leafId, + prompt: `ACTIVITY_UUID_RIGHT_${now}` + } + + await seedActivityThread( + page, + first, + 'Codex left pane', + 'blocked', + 'Left pane is waiting for user input.', + now - 2_000 + ) + await seedActivityThread( + page, + second, + 'Codex right pane', + 'done', + 'Right pane finished its turn.', + now - 1_000 + ) + + return [first, second] +} + +async function readActivityPaneVisibility(page: Page): Promise { + return page.evaluate(() => { + const slot = document.querySelector( + '[data-activity-terminal-slot-id]:not([aria-hidden="true"])' + ) + if (!slot) { + return { slotId: null, allLeafIds: [], visibleLeafIds: [] } + } + + const hasInlineDisplayNoneBetween = (element: HTMLElement, root: HTMLElement): boolean => { + let current: HTMLElement | null = element + while (current) { + if (current.style.display === 'none') { + return true + } + if (current === root) { + return false + } + current = current.parentElement + } + return false + } + + const panes = Array.from(slot.querySelectorAll('[data-leaf-id]')) + return { + slotId: slot.dataset.activityTerminalSlotId ?? null, + allLeafIds: panes.map((pane) => pane.dataset.leafId ?? ''), + visibleLeafIds: panes + .filter((pane) => !hasInlineDisplayNoneBetween(pane, slot)) + .map((pane) => pane.dataset.leafId ?? '') + } + }) +} + +async function enableInlineAgentCards(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + if (!state.worktreeCardProperties.includes('inline-agents')) { + state.toggleWorktreeCardProperty('inline-agents') + } + state.closeActivityPage() + }) +} + +async function clickWorkspaceCardAgentRow(page: Page, prompt: string): Promise { + const rowLabel = page + .getByRole('group', { name: 'Agents' }) + .locator(`span[title="${prompt}"]`) + .first() + await expect(rowLabel).toBeVisible({ timeout: 10_000 }) + await rowLabel.click() +} + +async function readActivePaneSelection(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return { + activeWorktreeId: null, + activeGroupId: null, + activeTabId: null, + activeLeafId: null, + activePaneId: null + } + } + + const state = store.getState() + const activeWorktreeId = state.activeWorktreeId ?? null + const activeGroupId = activeWorktreeId + ? (state.activeGroupIdByWorktree[activeWorktreeId] ?? null) + : null + const activeTabId = state.activeTabId ?? null + const activePane = activeTabId + ? (window.__paneManagers?.get(activeTabId)?.getActivePane?.() ?? null) + : null + + return { + activeWorktreeId, + activeGroupId, + activeTabId, + activeLeafId: activePane?.leafId ?? null, + activePaneId: activePane?.id ?? null + } + }) +} + +async function createTerminalInNewSplitGroup(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree for split-group terminal setup') + } + const sourceGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? state.groupsByWorktree[worktreeId]?.[0]?.id + if (!sourceGroupId) { + throw new Error('No source group for split-group terminal setup') + } + + const groupId = state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right') + if (!groupId) { + throw new Error('Failed to create split group') + } + + const tab = state.createTab(worktreeId, groupId, undefined, { activate: true }) + state.focusGroup(worktreeId, groupId) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return { sourceGroupId, groupId, tabId: tab.id } + }) +} + +test.describe('Activity Agent Pane Isolation', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) + .then(() => true) + .catch(() => false) + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounts the live TerminalPane manager, so Activity pane isolation would only fail on harness setup.' + ) + await waitForPaneCount(orcaPage, 1, 30_000) + }) + + test('selecting agent rows isolates the matching split pane by stable leaf id', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + const [first, second] = await seedActivityThreadsForSplitPanes(orcaPage, snapshot) + + await orcaPage.getByRole('button', { name: /Agents/ }).click() + await expect(orcaPage.getByText(first.prompt)).toBeVisible() + await expect(orcaPage.getByText(second.prompt)).toBeVisible() + + await orcaPage.getByRole('button').filter({ hasText: first.prompt }).first().click() + await expect + .poll(async () => readActivityPaneVisibility(orcaPage), { + timeout: 10_000, + message: 'Activity did not isolate the first selected split pane' + }) + .toMatchObject({ + allLeafIds: expect.arrayContaining([first.leafId, second.leafId]), + visibleLeafIds: [first.leafId] + }) + + await orcaPage.getByRole('button').filter({ hasText: second.prompt }).first().click() + await expect + .poll(async () => readActivityPaneVisibility(orcaPage), { + timeout: 10_000, + message: 'Activity did not switch isolation to the second selected split pane' + }) + .toMatchObject({ + allLeafIds: expect.arrayContaining([first.leafId, second.leafId]), + visibleLeafIds: [second.leafId] + }) + }) + + test('workspace card agent rows focus the matching terminal split pane', async ({ orcaPage }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + const [first, second] = await seedActivityThreadsForSplitPanes(orcaPage, snapshot) + + await enableInlineAgentCards(orcaPage) + + await clickWorkspaceCardAgentRow(orcaPage, first.prompt) + await expect + .poll(async () => readActivePaneSelection(orcaPage), { + timeout: 10_000, + message: 'Workspace-card row did not focus the first split pane' + }) + .toMatchObject({ + activeTabId: snapshot.tabId, + activeLeafId: first.leafId + }) + + await clickWorkspaceCardAgentRow(orcaPage, second.prompt) + await expect + .poll(async () => readActivePaneSelection(orcaPage), { + timeout: 10_000, + message: 'Workspace-card row did not focus the second split pane' + }) + .toMatchObject({ + activeTabId: snapshot.tabId, + activeLeafId: second.leafId + }) + }) + + test('workspace card agent rows focus the matching split-group terminal pane', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + const firstGroupSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + const [first, second] = await seedActivityThreadsForSplitPanes(orcaPage, firstGroupSnapshot) + + const splitGroup = await createTerminalInNewSplitGroup(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 1, 30_000) + const secondGroupSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const secondGroupPane = secondGroupSnapshot.panes[0] + if (!secondGroupPane) { + throw new Error('Split-group terminal did not mount a pane') + } + const now = Date.now() + const splitGroupThread: SeededActivityThread = { + paneKey: `${secondGroupSnapshot.tabId}:${secondGroupPane.leafId}`, + leafId: secondGroupPane.leafId, + prompt: `ACTIVITY_UUID_SPLIT_GROUP_${now}` + } + await seedActivityThread( + orcaPage, + splitGroupThread, + 'Codex split group pane', + 'blocked', + 'Split group pane is waiting for user input.', + now + ) + + await enableInlineAgentCards(orcaPage) + + await clickWorkspaceCardAgentRow(orcaPage, splitGroupThread.prompt) + await expect + .poll(async () => readActivePaneSelection(orcaPage), { + timeout: 10_000, + message: 'Workspace-card row did not focus the split-group terminal pane' + }) + .toMatchObject({ + activeGroupId: splitGroup.groupId, + activeTabId: secondGroupSnapshot.tabId, + activeLeafId: splitGroupThread.leafId + }) + + await clickWorkspaceCardAgentRow(orcaPage, first.prompt) + await expect + .poll(async () => readActivePaneSelection(orcaPage), { + timeout: 10_000, + message: 'Workspace-card row did not return to the first split group' + }) + .toMatchObject({ + activeGroupId: splitGroup.sourceGroupId, + activeTabId: firstGroupSnapshot.tabId, + activeLeafId: first.leafId + }) + + await clickWorkspaceCardAgentRow(orcaPage, second.prompt) + await expect + .poll(async () => readActivePaneSelection(orcaPage), { + timeout: 10_000, + message: 'Workspace-card row did not focus the sibling pane after group switch' + }) + .toMatchObject({ + activeGroupId: splitGroup.sourceGroupId, + activeTabId: firstGroupSnapshot.tabId, + activeLeafId: second.leafId + }) + }) +}) diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index 70c3576b2..6b075f674 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -1,6 +1,22 @@ +/* eslint-disable max-lines -- Terminal E2E helpers share one PaneManager-backed path for PTY IO, split actions, and stable pane identity snapshots. */ import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' +export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +export type PaneIdentitySnapshot = { + tabId: string + activeLeafId: string | null + panes: { + numericPaneId: number + leafId: string + stablePaneId: string + datasetLeafId: string | null + ptyId: string | null + }[] + ptyIdsByLeafId: Record +} + // Why: worktree restoration can render the terminal surface before the legacy // global activeTabId settles. Prefer the active worktree's saved terminal tab // pointer, then fall back to the first terminal tab. @@ -175,6 +191,117 @@ export async function discoverActivePtyId(page: Page): Promise { return foundPtyId } +export async function readPaneIdentitySnapshot(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return null + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const store = window.__store + if (!manager || !store) { + return null + } + + const activePane = manager.getActivePane?.() ?? null + return { + tabId, + activeLeafId: activePane?.leafId ?? null, + panes: manager.getPanes().map((pane) => ({ + numericPaneId: pane.id, + leafId: pane.leafId, + stablePaneId: pane.stablePaneId, + datasetLeafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null + })), + ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} + } + }, tabId) +} + +export async function waitForPaneIdentitySnapshot( + page: Page, + paneCount: number +): Promise { + await expect + .poll( + async () => { + const snapshot = await readPaneIdentitySnapshot(page) + return Boolean( + snapshot && + snapshot.panes.length === paneCount && + snapshot.panes.every( + (pane) => + UUID_RE.test(pane.leafId) && + pane.stablePaneId === pane.leafId && + pane.datasetLeafId === pane.leafId && + pane.ptyId !== null && + snapshot.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) + ) + }, + { + timeout: 15_000, + message: 'Split terminal panes did not settle with UUID leaf-keyed PTY bindings' + } + ) + .toBe(true) + + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + throw new Error('Pane identity snapshot disappeared after settling') + } + return snapshot +} + +export async function readTerminalPaneDomLeafOrder(page: Page): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + return [] + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return [] + } + const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) + return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) + .filter((element) => paneElements.has(element)) + .map((element) => element.dataset.leafId ?? '') + .filter((leafId) => leafId.length > 0) + }, snapshot.tabId) +} + +export async function moveTerminalPaneByLeafId( + page: Page, + sourceLeafId: string, + targetLeafId: string, + zone: 'top' | 'bottom' | 'left' | 'right' +): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + throw new Error('moveTerminalPaneByLeafId: no active terminal tab') + } + + await page.evaluate( + ({ tabId, sourceLeafId, targetLeafId, zone }) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') + } + const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) + const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) + if (sourcePaneId == null || targetPaneId == null) { + throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') + } + manager.movePane(sourcePaneId, targetPaneId, zone) + }, + { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } + ) +} + export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { await page.evaluate( ({ ptyId, text }) => { diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index 75aae7799..4fca1bc75 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -3,6 +3,7 @@ import os from 'os' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { + UUID_RE, execInTerminal, waitForActivePanePtyId, waitForActiveTerminalManager, @@ -196,8 +197,10 @@ test.describe('Localhost SSH', () => { if (!pane) { throw new Error('No active terminal pane') } - return `${tabId}:${pane.id}` + return `${tabId}:${pane.leafId}` }) + const paneKeyLeafId = paneKey.slice(paneKey.indexOf(':') + 1) + expect(paneKeyLeafId).toMatch(UUID_RE) const terminalMarker = marker('LOCALHOST_SSH') await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker)) diff --git a/tests/e2e/terminal-panes.spec.ts b/tests/e2e/terminal-panes.spec.ts index 2df63557e..3fd38b14c 100644 --- a/tests/e2e/terminal-panes.spec.ts +++ b/tests/e2e/terminal-panes.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Terminal pane E2E is a serial coverage matrix for split, close, remake, move, resize, and retention flows. */ /** * E2E tests for terminal pane splitting, state retention, resizing, and closing. * @@ -10,12 +11,16 @@ import { test, expect } from './helpers/orca-app' import { + UUID_RE, discoverActivePtyId, execInTerminal, closeActiveTerminalPane, countVisibleTerminalPanes, focusLastTerminalPane, + moveTerminalPaneByLeafId, + readTerminalPaneDomLeafOrder, splitActiveTerminalPane, + waitForPaneIdentitySnapshot, waitForActiveTerminalManager, waitForTerminalOutput, waitForPaneCount, @@ -93,6 +98,148 @@ test.describe('Terminal Panes', () => { expect(paneCountAfter).toBe(paneCountBefore + 1) }) + test('split panes persist PTY bindings by stable UUID leaf id', async ({ orcaPage }) => { + const paneCountBefore = await countVisibleTerminalPanes(orcaPage) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, paneCountBefore + 1) + + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, paneCountBefore + 1) + const leafIds = snapshot.panes.map((pane) => pane.leafId) + const ptyIds = snapshot.panes.map((pane) => pane.ptyId) + + expect(new Set(leafIds).size).toBe(leafIds.length) + expect(new Set(ptyIds).size).toBe(ptyIds.length) + expect(Object.keys(snapshot.ptyIdsByLeafId).sort()).toEqual([...leafIds].sort()) + expect(Object.keys(snapshot.ptyIdsByLeafId).every((leafId) => UUID_RE.test(leafId))).toBe(true) + expect( + snapshot.panes.some( + (pane) => + String(pane.numericPaneId) === pane.leafId || `pane:${pane.numericPaneId}` === pane.leafId + ) + ).toBe(false) + }) + + test('terminal process receives ORCA_PANE_KEY with the active UUID leaf id', async ({ + orcaPage + }) => { + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const activeLeafId = snapshot.activeLeafId ?? snapshot.panes[0]?.leafId + if (!activeLeafId) { + throw new Error('No active pane leaf id found') + } + + const expectedPaneKey = `${snapshot.tabId}:${activeLeafId}` + const ptyId = await discoverActivePtyId(orcaPage) + const marker = `ORCA_PANE_KEY_E2E_${Date.now()}` + + await execInTerminal(orcaPage, ptyId, `printf '${marker}=%s\\n' "$ORCA_PANE_KEY"`) + await waitForTerminalOutput(orcaPage, `${marker}=${expectedPaneKey}`) + + expect(activeLeafId).toMatch(UUID_RE) + }) + + test('closing a split pane prunes its leaf-keyed PTY binding without remapping siblings', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 3) + + const beforeClose = await waitForPaneIdentitySnapshot(orcaPage, 3) + const closedLeafId = beforeClose.activeLeafId ?? beforeClose.panes.at(-1)?.leafId + if (!closedLeafId) { + throw new Error('No active split pane leaf id found before close') + } + const survivingLeafIds = beforeClose.panes + .map((pane) => pane.leafId) + .filter((leafId) => leafId !== closedLeafId) + + await closeActiveTerminalPane(orcaPage) + await waitForPaneCount(orcaPage, 2) + + const afterClose = await waitForPaneIdentitySnapshot(orcaPage, 2) + expect(afterClose.panes.map((pane) => pane.leafId).sort()).toEqual(survivingLeafIds.sort()) + expect(Object.keys(afterClose.ptyIdsByLeafId).sort()).toEqual(survivingLeafIds.sort()) + expect(afterClose.ptyIdsByLeafId[closedLeafId]).toBeUndefined() + }) + + test('closing and remaking right/down splits keeps surviving leaf-keyed bindings stable', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 3) + + const beforeClose = await waitForPaneIdentitySnapshot(orcaPage, 3) + const closedLeafId = beforeClose.activeLeafId ?? beforeClose.panes.at(-1)?.leafId + if (!closedLeafId) { + throw new Error('No active split pane leaf id found before close/remake') + } + const survivingBindings = Object.fromEntries( + beforeClose.panes + .filter((pane) => pane.leafId !== closedLeafId) + .map((pane) => [pane.leafId, pane.ptyId]) + ) + + await closeActiveTerminalPane(orcaPage) + await waitForPaneCount(orcaPage, 2) + + const afterClose = await waitForPaneIdentitySnapshot(orcaPage, 2) + expect(Object.keys(afterClose.ptyIdsByLeafId).sort()).toEqual( + Object.keys(survivingBindings).sort() + ) + for (const [leafId, ptyId] of Object.entries(survivingBindings)) { + expect(afterClose.ptyIdsByLeafId[leafId]).toBe(ptyId) + } + expect(afterClose.ptyIdsByLeafId[closedLeafId]).toBeUndefined() + + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 3) + + const afterRemake = await waitForPaneIdentitySnapshot(orcaPage, 3) + const remadeLeafIds = afterRemake.panes.map((pane) => pane.leafId) + expect(remadeLeafIds).not.toContain(closedLeafId) + for (const [leafId, ptyId] of Object.entries(survivingBindings)) { + expect(afterRemake.ptyIdsByLeafId[leafId]).toBe(ptyId) + } + expect(new Set(remadeLeafIds).size).toBe(3) + }) + + test('moving panes through the drag-drop handler preserves leaf-keyed PTY bindings', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 3) + + const beforeMove = await waitForPaneIdentitySnapshot(orcaPage, 3) + const beforeOrder = await readTerminalPaneDomLeafOrder(orcaPage) + const source = beforeMove.panes.at(-1) + const target = beforeMove.panes[0] + if (!source || !target) { + throw new Error('Need source and target panes for move test') + } + const bindingsBefore = { ...beforeMove.ptyIdsByLeafId } + + await moveTerminalPaneByLeafId(orcaPage, source.leafId, target.leafId, 'left') + + await expect + .poll(async () => readTerminalPaneDomLeafOrder(orcaPage), { + timeout: 10_000, + message: 'Pane drag-drop move did not update DOM order' + }) + .not.toEqual(beforeOrder) + + const afterMove = await waitForPaneIdentitySnapshot(orcaPage, 3) + const afterLeafIds = afterMove.panes.map((pane) => pane.leafId).sort() + expect(afterLeafIds).toEqual(beforeMove.panes.map((pane) => pane.leafId).sort()) + expect(afterMove.ptyIdsByLeafId).toEqual(bindingsBefore) + }) + /** * User Prompt: * - terminal panes retain state when switching tabs and when you make / close a pane / switch worktrees @@ -280,6 +427,52 @@ test.describe('Terminal Panes', () => { .toBe(true) }) + test('@headful dragging terminal panes around preserves leaf-keyed PTY bindings', async ({ + orcaPage + }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 3) + + const beforeDrag = await waitForPaneIdentitySnapshot(orcaPage, 3) + const beforeOrder = await readTerminalPaneDomLeafOrder(orcaPage) + const source = beforeDrag.panes.at(-1) + const target = beforeDrag.panes[0] + if (!source || !target) { + throw new Error('Need source and target panes for drag test') + } + + const sourceHandle = orcaPage.locator( + `.pane[data-leaf-id="${source.leafId}"] .pane-drag-handle` + ) + await expect(sourceHandle).toBeVisible({ timeout: 3_000 }) + const sourceBox = await sourceHandle.boundingBox() + const targetBox = await orcaPage.locator(`.pane[data-leaf-id="${target.leafId}"]`).boundingBox() + expect(sourceBox).not.toBeNull() + expect(targetBox).not.toBeNull() + + await orcaPage.mouse.move(sourceBox!.x + sourceBox!.width / 2, sourceBox!.y + 4) + await orcaPage.mouse.down() + await orcaPage.mouse.move(targetBox!.x + 8, targetBox!.y + targetBox!.height / 2, { + steps: 20 + }) + await orcaPage.mouse.up() + + await expect + .poll(async () => readTerminalPaneDomLeafOrder(orcaPage), { + timeout: 10_000, + message: 'Real pane drag did not update DOM order' + }) + .not.toEqual(beforeOrder) + + const afterDrag = await waitForPaneIdentitySnapshot(orcaPage, 3) + expect(afterDrag.panes.map((pane) => pane.leafId).sort()).toEqual( + beforeDrag.panes.map((pane) => pane.leafId).sort() + ) + expect(afterDrag.ptyIdsByLeafId).toEqual(beforeDrag.ptyIdsByLeafId) + }) + /** * User Prompt: * - closing panes works diff --git a/tests/e2e/worktree-smart-sort.spec.ts b/tests/e2e/worktree-smart-sort.spec.ts index 0430f5fa3..f68e949e5 100644 --- a/tests/e2e/worktree-smart-sort.spec.ts +++ b/tests/e2e/worktree-smart-sort.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './helpers/orca-app' import type { Page } from '@stablyai/playwright-test' +import type { TerminalPaneLayoutNode } from '../../src/shared/types' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' type SmartSortScenario = { @@ -85,31 +86,72 @@ async function seedSmartSortScenario(page: Page): Promise { throw new Error('Smart sort E2E failed to create terminal tabs') } + const blockedPtyId = stateWithTabs.ptyIdsByTabId[blockedTab.id]?.[0] ?? `e2e-${blockedTab.id}` + const donePtyId = stateWithTabs.ptyIdsByTabId[doneTab.id]?.[0] ?? `e2e-${doneTab.id}` + const firstLayoutLeafId = (node: TerminalPaneLayoutNode | null | undefined): string | null => { + if (!node) { + return null + } + return node.type === 'leaf' + ? node.leafId + : (firstLayoutLeafId(node.first) ?? firstLayoutLeafId(node.second)) + } + let blockedLeafId = '' + let doneLeafId = '' + // Why: WorktreeList intentionally holds cold-start ordering until a live // PTY exists. E2E hidden windows can create tabs before panes mount, so - // seed the live-PTY map explicitly and let agent-status writes drive the - // same sortEpoch path that hook events use in the app. - store.setState((current) => ({ - ptyIdsByTabId: { - ...current.ptyIdsByTabId, - [blockedTab.id]: current.ptyIdsByTabId[blockedTab.id]?.length - ? current.ptyIdsByTabId[blockedTab.id] - : [`e2e-${blockedTab.id}`], - [doneTab.id]: current.ptyIdsByTabId[doneTab.id]?.length - ? current.ptyIdsByTabId[doneTab.id] - : [`e2e-${doneTab.id}`] + // seed the live-PTY and stable-layout maps explicitly and let agent-status + // writes drive the same sortEpoch path that hook events use in the app. + store.setState((current) => { + const blockedLayout = current.terminalLayoutsByTabId[blockedTab.id] + const doneLayout = current.terminalLayoutsByTabId[doneTab.id] + blockedLeafId = firstLayoutLeafId(blockedLayout?.root) ?? crypto.randomUUID() + doneLeafId = firstLayoutLeafId(doneLayout?.root) ?? crypto.randomUUID() + + return { + ptyIdsByTabId: { + ...current.ptyIdsByTabId, + [blockedTab.id]: current.ptyIdsByTabId[blockedTab.id]?.length + ? current.ptyIdsByTabId[blockedTab.id] + : [blockedPtyId], + [doneTab.id]: current.ptyIdsByTabId[doneTab.id]?.length + ? current.ptyIdsByTabId[doneTab.id] + : [donePtyId] + }, + terminalLayoutsByTabId: { + ...current.terminalLayoutsByTabId, + [blockedTab.id]: { + root: blockedLayout?.root ?? { type: 'leaf', leafId: blockedLeafId }, + activeLeafId: blockedLayout?.activeLeafId ?? blockedLeafId, + expandedLeafId: blockedLayout?.expandedLeafId ?? null, + ptyIdsByLeafId: { + ...blockedLayout?.ptyIdsByLeafId, + [blockedLeafId]: blockedPtyId + } + }, + [doneTab.id]: { + root: doneLayout?.root ?? { type: 'leaf', leafId: doneLeafId }, + activeLeafId: doneLayout?.activeLeafId ?? doneLeafId, + expandedLeafId: doneLayout?.expandedLeafId ?? null, + ptyIdsByLeafId: { + ...doneLayout?.ptyIdsByLeafId, + [doneLeafId]: donePtyId + } + } + } } - })) + }) const actions = store.getState() actions.setAgentStatus( - `${doneTab.id}:1`, + `${doneTab.id}:${doneLeafId}`, { state: 'done', prompt: 'Finished', agentType: 'codex' }, 'codex', { updatedAt: now, stateStartedAt: now - 1_000 } ) actions.setAgentStatus( - `${blockedTab.id}:1`, + `${blockedTab.id}:${blockedLeafId}`, { state: 'blocked', prompt: 'Needs approval', agentType: 'codex' }, 'codex', { updatedAt: now, stateStartedAt: now - 60_000 }