Migrate agent pane identity to stable leaf ids (#1909)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-15 12:10:06 -07:00 committed by GitHub
parent 67c67cd6fa
commit 60fdd11114
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
106 changed files with 5229 additions and 1059 deletions

View File

@ -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<string, MigrationUnsupportedPtyEntry>()
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)
}
}
}

View File

@ -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(

View File

@ -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<string, EnrichedAgentHookEventPayload>
}
// 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()

View File

@ -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

View File

@ -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
}

View File

@ -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<string, (event: unknown, ...args: unknown[]) => void>
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => 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) {

View File

@ -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

View File

@ -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(() => {})

View File

@ -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<string, number>()
// and cleared on PTY teardown.
const rendererSerializerByPtyId = new Set<string>()
function parseValidPaneKey(paneKey: unknown): ReturnType<typeof parsePaneKey> {
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<string, string> | 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<string, string> 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

File diff suppressed because it is too large Load Diff

View File

@ -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<string, string>
}
function collectLayoutLeafCounts(
node: TerminalPaneLayoutNode,
counts: Map<string, number> = new Map()
): Map<string, number> {
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<string, string>,
duplicatedInputLeafIds: Set<string>
): 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<string, string> | undefined,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): Record<string, string> | undefined {
if (!source) {
return undefined
}
const next: Record<string, string> = {}
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<string, string> | undefined,
right: Record<string, string> | 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<string, string> | undefined,
incomingRecord: Record<string, string> | undefined,
liveLeafIds: Set<string>
): Record<string, string> | 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<string, string>
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<string, string>()
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<string, TerminalLayoutSnapshot> = {},
sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local'
): {
session: WorkspaceSessionState
changed: boolean
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
} {
let changed = false
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
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<string, string>()
const duplicatePtyIds = new Set<string>()
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<string, Map<string, string>>,
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
): { 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<string, MigrationUnsupportedPtyEntry>()
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<MigrationUnsupportedPtyEntry>
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<typeof setTimeout> | null = null
@ -278,7 +808,21 @@ export class Store {
private gitUsernameCache = new Map<string, string>()
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<Pick<SshRemotePtyLease, 'createdAt' | 'updatedAt'>>
): 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)
}

View File

@ -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, string>): 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<string, string> } | 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<string, string> } | 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<string, string> } | 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<string, string> } | 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<string, string> } | 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
})
})

View File

@ -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<string, string>; title?: string; focus?: boolean } = {}
opts: {
command?: string
env?: Record<string, string>
title?: string
focus?: boolean
tabId?: string
leafId?: string
} = {}
): Promise<RuntimeTerminalCreate> {
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<RuntimeSyncedLeaf, 'tabId' | 'leafId' | 'paneRuntimeId'>
): 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) {

View File

@ -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
})
})
}),

View File

@ -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> = {}): 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',

View File

@ -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) => {

View File

@ -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<AgentStatusIpcPayload[]>
/** 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<MigrationUnsupportedPtyEntry[]>
/** Drop a paneKey from the main-process hook cache and the on-disk
* last-status file. Fire-and-forget. */
drop: (paneKey: string) => void

View File

@ -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<AgentStatusIpcPayload[]> =>
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<MigrationUnsupportedPtyEntry[]> =>
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

View File

@ -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}`)
})
})

View File

@ -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)

View File

@ -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)}
/>

View File

@ -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

View File

@ -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', () => {

View File

@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-pty-id]') !== null
const hasXtermScreen = selectedRoot.querySelector<HTMLElement>('.xterm-screen') !== null
return { hasSelectedRoot: true, ready: hasPtyBinding && hasXtermScreen }
selectedPane.hasAttribute('data-pty-id') ||
selectedPane.querySelector<HTMLElement>('[data-pty-id]') !== null
const hasXtermScreen = selectedPane.querySelector<HTMLElement>('.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<ActivityTerminalPortalReadiness>({
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<string, AgentStatusEntry>
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
tabsByWorktree: Record<string, TerminalTab[]>
worktreeMap: Map<string, Worktree>
@ -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<ActivityTerminalPortalSlotId, HTMLElement | null>
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 ? (
<div className="ml-3 mt-3 inline-flex items-center gap-2 rounded-md border border-border bg-background/85 px-2 py-1 text-xs text-muted-foreground shadow-xs">
<span className="h-3 w-1.5 rounded-sm bg-muted-foreground/70" />
<span>Terminal unavailable</span>
</div>
) : showTerminalLoadingLabel ? (
<div className="ml-3 mt-3 inline-flex items-center gap-2 rounded-md border border-border bg-background/85 px-2 py-1 text-xs text-muted-foreground shadow-xs">
<span className="h-3 w-1.5 animate-pulse rounded-sm bg-muted-foreground/70" />
<span>Connecting terminal...</span>

View File

@ -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
})
}

View File

@ -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
)
}

View File

@ -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<string, Worktree[]>,
tabsByWorktree: Record<string, TerminalTab[]>,
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>,
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<string, AgentStatusEntry[]>()
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
]
)
}

View File

@ -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: {

View File

@ -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()
})
})

View File

@ -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

View File

@ -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}:`)) {

View File

@ -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
})

View File

@ -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

View File

@ -859,7 +859,9 @@ const WorktreeList = React.memo(function WorktreeList() {
state.agentStatusByPaneKey,
state.runtimePaneTitlesByTabId,
state.ptyIdsByTabId,
now
now,
state.migrationUnsupportedByPtyId,
state.terminalLayoutsByTabId
)
: new Map<string, WorktreeAttention>()
lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null

View File

@ -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<string, TerminalLayoutSnapshot> {
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<AgentStatusEntry> & { 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<string, AgentStatusEntry> = {
'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<string, AgentStatusEntry> = {
'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<string, AgentStatusEntry> = {
'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,

View File

@ -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<string, AgentStatusEntry> | undefined
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>
): Map<string, AgentStatusEntry[]> {
const byTab = new Map<string, AgentStatusEntry[]>()
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<TerminalPaneLayoutNode, { type: 'split' }>,
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<string, AgentStatusEntry> | undefined,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
ptyIdsByTabId: Record<string, string[]>,
now: number
now: number,
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>,
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
): Map<string, WorktreeAttention> {
const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey)
const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey, migrationUnsupportedByPtyId)
const result = new Map<string, WorktreeAttention>()
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<number>()
const hookLeafIds = new Set<string>()
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.

View File

@ -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<string, Repo>([
[
@ -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<string, AgentStatusEntry> = {
'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,

View File

@ -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<string, Repo>,
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
ptyIdsByTabId: Record<string, string[]>
ptyIdsByTabId: Record<string, string[]>,
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>,
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
): 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))

View File

@ -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

View File

@ -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')
})

View File

@ -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<string, AgentStatusEntry[]>()
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)

View File

@ -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

View File

@ -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]
)

View File

@ -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}`
}
}

View File

@ -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, {

View File

@ -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 (
<TerminalOverlaySlot
key={terminalTab.id}

View File

@ -0,0 +1,39 @@
import type { FocusTerminalPaneDetail } from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution'
type FocusTerminalPaneEventDeps = {
tabId: string
manager: Pick<PaneManager, 'getNumericIdForLeaf' | 'getPanes' | 'setActivePane'> | 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])
}
}

View File

@ -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
})
})

View File

@ -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<TerminalPaneLayoutNode, { type: 'split' }>,
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<number, string>
): 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<string, number>,
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<string, number> {
const paneByLeafId = new Map<string, number>()
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
}

View File

@ -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<void> {
}
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<string, { id: string; ptyId: string | null; title?: string }[]>
@ -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)
})
})

View File

@ -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<string, Promise<string | null>>()
@ -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<string, unknown>).__ptyConnectDiag as string[])?.push(
`pane=${pane.id} → PENDING SPAWN`
)

View File

@ -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

View File

@ -201,6 +201,8 @@ export function createRemoteRuntimePtyTransport(
worktree: worktreeId,
command,
env,
tabId,
leafId,
focus: false
})
handle = created.terminal.handle

View File

@ -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))
}

View File

@ -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<string, TerminalLeafId>
duplicatedInputLeafIds: Set<string>
}
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<string, string> | undefined,
rewrite: LeafIdRewrite
): Record<string, string> | undefined {
if (!source) {
return undefined
}
const next: Record<string, string> = {}
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<string, number> = new Map()
): Map<string, number> {
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<string, TerminalLeafId>()
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<TerminalPaneLayoutNode, { type: 'split' }>,
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
}

View File

@ -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<TerminalLayoutSnapshot>({
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' })
})
})

View File

@ -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<ManagedPane, 'id' | 'terminal' | 'serializeAddon'>
type ShutdownPane = Pick<ManagedPane, 'id' | 'leafId' | 'terminal' | 'serializeAddon'>
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)
}

View File

@ -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<FocusTerminalPaneDetail | undefined>).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)

View File

@ -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)
}

View File

@ -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 = {

View File

@ -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)

View File

@ -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<string, TerminalLayoutSnapshot> },
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<string, RetainedAgentEntry>
acknowledgedAgentsByPaneKey: Record<string, number>
},
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)
}

View File

@ -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

View File

@ -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

View File

@ -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<typeof useAppStore.getState>,
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

View File

@ -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<FocusTerminalPaneDetail>(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId, paneId }
detail
})
)
})

View File

@ -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()
})

View File

@ -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

View File

@ -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 => {}

View File

@ -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.'
}
}

View File

@ -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
}

View File

@ -10,9 +10,13 @@ type FitOverride = {
}
const overridesByPtyId = new Map<string, FitOverride>()
// 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<string, string>()
// 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<string, string>()
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))
}
}

View File

@ -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

View File

@ -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<number, TerminalLeafId> = new Map()
private numericIdByLeafId: Map<TerminalLeafId, number> = new Map()
private publishedPaneIds: Set<number> = 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<number, TerminalLeafId> {
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
}
}

View File

@ -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<ManagedPane, 'id' | 'leafId'>[]
}) {
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'
})
})
})

View File

@ -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 }
}

View File

@ -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,

View File

@ -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,

View File

@ -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<void>
onPaneClosed?: (paneId: number) => void
onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void
onActivePaneChange?: (pane: ManagedPane) => void
onLayoutChanged?: () => void
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
@ -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

View File

@ -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
// ~816 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<number, TerminalLeafId> {
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<NonNullable<PaneManagerOptions['onPaneCreated']>>[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({

View File

@ -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,

View File

@ -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<number, ManagedPaneInternal>
root: HTMLElement
styleOptions: PaneStyleOptions
managerOptions: PaneManagerOptions
createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal
createDivider: (isVertical: boolean) => HTMLElement
publishPaneCreated: (
pane: ManagedPaneInternal,
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[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<number, ManagedPaneInternal>
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
}

View File

@ -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<typeof vi.fn> }
@ -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(),

View File

@ -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

View File

@ -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,

View File

@ -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<void> {
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),

View File

@ -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<string, AgentStatusEntry>
/** PTYs that still report legacy numeric pane keys but have registry-backed
* UUID pane proof. Stored separately from normal hook-reported status. */
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>
/** 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<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
): { next: Record<string, MigrationUnsupportedPtyEntry>; changed: boolean } {
let changed = false
const next: Record<string, MigrationUnsupportedPtyEntry> = {}
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<AppState, [], [], AgentStatusSlice> = (
set,
get
@ -142,6 +165,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
agentStatusEpoch: 0,
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
@ -265,11 +289,20 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys }
delete nextRetentionSuppressedPaneKeys[paneKey]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => 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<AppState, [], [], AgentStatusS
queueMicrotask(() => 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<AppState, [], [], AgentStatusS
// as setAgentStatus.
return {
agentStatusByPaneKey: next,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
@ -313,7 +389,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const prefix = `${tabIdPrefix}:`
const currentKeys = Object.keys(get().agentStatusByPaneKey)
const toRemove = currentKeys.filter((k) => 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<AppState, [], [], AgentStatusS
for (const key of toRemove) {
delete next[key]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => 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<AppState, [], [], AgentStatusS
// no keys matched the prefix.
return {
agentStatusByPaneKey: next,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
@ -356,6 +440,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const hasLive = paneKey in s.agentStatusByPaneKey
liveExisted = hasLive
const hasRetained = paneKey in s.retainedAgentsByPaneKey
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => 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<AppState, [], [], AgentStatusS
// paneKey with no live and no retained entry truly has nothing to
// change, so short-circuit here — but still flush a pending ack
// cleanup if one is present.
if (!hasLive && !hasRetained) {
if (!hasLive && !hasRetained && !migrationUnsupported.changed) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@ -423,6 +511,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
@ -434,13 +523,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
}
: {}),
agentStatusEpoch: hasLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
agentStatusEpoch:
hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
// Why: mirrors removeAgentStatus — dropping a live working/blocked
// agent changes its contribution to the worktree sort score, so the
// sidebar smart-sort must recompute. Without this bump, a user-
// initiated dismissal from the inline agents list would leave the
// sidebar ordering stale until some unrelated event repaired it.
sortEpoch: hasLive ? s.sortEpoch + 1 : s.sortEpoch
sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
// Why: freshness.schedule only matters when the live map changed —
@ -470,6 +560,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter((k) =>
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<AppState, [], [], AgentStatusS
delete nextAck[k]
}
}
if (liveKeys.length === 0 && retainedKeys.length === 0) {
if (liveKeys.length === 0 && retainedKeys.length === 0 && !migrationUnsupported.changed) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@ -527,6 +621,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
@ -534,8 +629,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// Why: mirrors removeAgentStatusByTabPrefix — only bump the live-map
// epoch / sortEpoch when the live map actually changed. Retained-only
// sweeps do not participate in smart-sort or freshness calculations.
agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch
agentStatusEpoch:
hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {
@ -557,6 +653,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
)
.map(([paneKey]) => 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<AppState, [], [], AgentStatusS
// changed, narrow the return to just the ack delta (or s) so we don't
// emit a new top-level state object that re-renders full-state
// subscribers for nothing.
if (liveKeys.length === 0 && retainedKeys.length === 0) {
if (liveKeys.length === 0 && retainedKeys.length === 0 && !migrationUnsupported.changed) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@ -608,12 +710,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch
agentStatusEpoch:
hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {

View File

@ -1614,10 +1614,9 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
})
// 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()

View File

@ -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)
})
})

View File

@ -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,

View File

@ -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<string, string> } | 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<string, number | null>
setCacheTimerStartedAt: (key: string, ts: number | null) => void
@ -283,7 +284,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
// 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 `<spaces>: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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
// 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]
})
)
}
})

View File

@ -160,6 +160,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
agentStatus: {
onSet: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {}
},
mobile: {

View File

@ -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',

View File

@ -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<string, unknown>
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<string, unknown>).hook_event_name

View File

@ -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

View File

@ -317,6 +317,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
workspaceSession: getDefaultWorkspaceSession(),
sshTargets: [],
sshRemotePtyLeases: [],
migrationUnsupportedPtyEntries: [],
automations: [],
automationRuns: [],
onboarding: getDefaultOnboardingState()

View File

@ -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

View File

@ -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()
})
})

View File

@ -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 }
}

View File

@ -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<string, string>
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
buffersByLeafId?: Record<string, string>
/** 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<string, string>
}
@ -1801,6 +1802,7 @@ export type PersistedState = {
workspaceSession: WorkspaceSessionState
sshTargets: SshTarget[]
sshRemotePtyLeases: SshRemotePtyLease[]
migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[]
automations: Automation[]
automationRuns: AutomationRun[]
onboarding: OnboardingState

Some files were not shown because too many files have changed in this diff Show More