Migrate agent pane identity to stable leaf ids (#1909)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
67c67cd6fa
commit
60fdd11114
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}:`)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ export function createRemoteRuntimePtyTransport(
|
|||
worktree: worktreeId,
|
||||
command,
|
||||
env,
|
||||
tabId,
|
||||
leafId,
|
||||
focus: false
|
||||
})
|
||||
handle = created.terminal.handle
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,35 +11,21 @@ import {
|
|||
applyPaneOpacity,
|
||||
applyRootBackground
|
||||
} from './pane-divider'
|
||||
import {
|
||||
createDragReorderState,
|
||||
hideDropOverlay,
|
||||
handlePaneDrop,
|
||||
updateMultiPaneState
|
||||
} from './pane-drag-reorder'
|
||||
import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder'
|
||||
import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle'
|
||||
import { disposeWebgl } from './pane-webgl-renderer'
|
||||
import { shouldFollowMouseFocus } from './focus-follows-mouse'
|
||||
import {
|
||||
findPaneChildren,
|
||||
removeDividers,
|
||||
promoteSibling,
|
||||
wrapInSplit,
|
||||
safeFit,
|
||||
fitAllPanesInternal,
|
||||
captureScrollState,
|
||||
refitPanesUnder
|
||||
} from './pane-tree-ops'
|
||||
import { scheduleSplitScrollRestore } from './pane-split-scroll'
|
||||
import { safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops'
|
||||
import { toPublicPane } from './pane-public-view'
|
||||
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import {
|
||||
markPaneComplexScriptOutput,
|
||||
resumePaneRendering,
|
||||
setPaneGpuRenderingState,
|
||||
suspendPaneRendering
|
||||
} from './pane-rendering-control'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { PaneIdentityRegistry } from './pane-identity-registry'
|
||||
import { closeManagedPane, splitManagedPane } from './pane-split-close'
|
||||
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
|
||||
|
||||
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
||||
|
|
@ -53,6 +39,7 @@ export class PaneManager {
|
|||
private styleOptions: PaneStyleOptions = {}
|
||||
private destroyed = false
|
||||
private renderingSuspended: boolean
|
||||
private identities = new PaneIdentityRegistry()
|
||||
|
||||
// Drag-to-reorder state
|
||||
private dragState = createDragReorderState()
|
||||
|
|
@ -63,8 +50,8 @@ export class PaneManager {
|
|||
this.renderingSuspended = options.initialRenderingSuspended === true
|
||||
}
|
||||
|
||||
createInitialPane(opts?: { focus?: boolean }): ManagedPane {
|
||||
const pane = this.createPaneInternal()
|
||||
createInitialPane(opts?: { focus?: boolean; leafId?: string }): ManagedPane {
|
||||
const pane = this.createPaneInternal(opts?.leafId)
|
||||
Object.assign(pane.container.style, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -80,99 +67,48 @@ export class PaneManager {
|
|||
pane.terminal.focus()
|
||||
}
|
||||
|
||||
void this.options.onPaneCreated?.(toPublicPane(pane))
|
||||
this.publishPaneCreated(pane)
|
||||
return toPublicPane(pane)
|
||||
}
|
||||
|
||||
splitPane(
|
||||
paneId: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
opts?: { ratio?: number; cwd?: string }
|
||||
opts?: { ratio?: number; cwd?: string; leafId?: string }
|
||||
): ManagedPane | null {
|
||||
const existing = this.panes.get(paneId)
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
const newPane = this.createPaneInternal()
|
||||
const parent = existing.container.parentElement
|
||||
if (!parent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isVertical = direction === 'vertical'
|
||||
const divider = this.createDividerWrapped(isVertical)
|
||||
|
||||
// Why: wrapInSplit reparents the existing container, resetting scrollTop.
|
||||
const scrollState = captureScrollState(existing.terminal)
|
||||
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during
|
||||
// the async settle window — scheduleSplitScrollRestore owns the restore.
|
||||
existing.pendingSplitScrollState = scrollState
|
||||
|
||||
// Why: DOM reparenting can silently invalidate a WebGL context without
|
||||
// firing contextlost — Chromium reclaims the oldest context near its
|
||||
// ~8–16 limit. Dispose before the move, reattach in the 200ms timer.
|
||||
const hadWebgl = !!existing.webglAddon
|
||||
disposeWebgl(existing)
|
||||
|
||||
wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
|
||||
|
||||
openTerminal(newPane)
|
||||
this.activePaneId = newPane.id
|
||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||
applyDividerStyles(this.root, this.styleOptions)
|
||||
newPane.terminal?.focus()
|
||||
updateMultiPaneState(this.getDragCallbacks())
|
||||
// Why: forward cwd hint so the new PTY spawns in the source pane's cwd.
|
||||
void this.options.onPaneCreated?.(
|
||||
toPublicPane(newPane),
|
||||
opts?.cwd ? { cwd: opts.cwd } : undefined
|
||||
)
|
||||
this.options.onLayoutChanged?.()
|
||||
|
||||
const reattach = hadWebgl ? reattachWebglIfNeeded : undefined
|
||||
scheduleSplitScrollRestore(
|
||||
(id) => this.panes.get(id),
|
||||
existing.id,
|
||||
scrollState,
|
||||
() => this.destroyed,
|
||||
reattach
|
||||
)
|
||||
|
||||
return toPublicPane(newPane)
|
||||
return splitManagedPane({
|
||||
paneId,
|
||||
direction,
|
||||
opts,
|
||||
panes: this.panes,
|
||||
root: this.root,
|
||||
styleOptions: this.styleOptions,
|
||||
managerOptions: this.options,
|
||||
createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint),
|
||||
createDivider: (isVertical) => this.createDividerWrapped(isVertical),
|
||||
publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints),
|
||||
getDragCallbacks: () => this.getDragCallbacks(),
|
||||
setActivePaneId: (id) => {
|
||||
this.activePaneId = id
|
||||
},
|
||||
isDestroyed: () => this.destroyed
|
||||
})
|
||||
}
|
||||
|
||||
closePane(paneId: number): void {
|
||||
const pane = this.panes.get(paneId)
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
const paneContainer = pane.container
|
||||
const parent = paneContainer.parentElement
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
disposePane(pane, this.panes)
|
||||
if (parent.classList.contains('pane-split')) {
|
||||
const siblings = findPaneChildren(parent)
|
||||
const sibling = siblings.find((c) => c !== paneContainer) ?? null
|
||||
paneContainer.remove()
|
||||
removeDividers(parent)
|
||||
promoteSibling(sibling, parent, this.root)
|
||||
} else {
|
||||
paneContainer.remove()
|
||||
}
|
||||
if (this.activePaneId === paneId) {
|
||||
const next = this.panes.values().next().value as ManagedPaneInternal | undefined
|
||||
this.activePaneId = next?.id ?? null
|
||||
next?.terminal.focus()
|
||||
}
|
||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||
for (const p of this.panes.values()) {
|
||||
safeFit(p)
|
||||
}
|
||||
updateMultiPaneState(this.getDragCallbacks())
|
||||
this.options.onPaneClosed?.(paneId)
|
||||
this.options.onLayoutChanged?.()
|
||||
closeManagedPane({
|
||||
paneId,
|
||||
activePaneId: this.activePaneId,
|
||||
panes: this.panes,
|
||||
root: this.root,
|
||||
styleOptions: this.styleOptions,
|
||||
managerOptions: this.options,
|
||||
getDragCallbacks: () => this.getDragCallbacks(),
|
||||
releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId),
|
||||
setActivePaneId: (id) => {
|
||||
this.activePaneId = id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getPanes(): ManagedPane[] {
|
||||
|
|
@ -191,6 +127,26 @@ export class PaneManager {
|
|||
return pane ? toPublicPane(pane) : null
|
||||
}
|
||||
|
||||
getLeafId(numericPaneId: number): TerminalLeafId | null {
|
||||
return this.identities.getLeafId(numericPaneId)
|
||||
}
|
||||
|
||||
getNumericIdForLeaf(leafId: string): number | null {
|
||||
return this.identities.getNumericIdForLeaf(leafId)
|
||||
}
|
||||
|
||||
getLeafIdMap(): Map<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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
agentStatus: {
|
||||
onSet: () => noopUnsubscribe,
|
||||
getSnapshot: () => Promise.resolve([]),
|
||||
onMigrationUnsupported: () => noopUnsubscribe,
|
||||
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
||||
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
||||
drop: () => {}
|
||||
},
|
||||
mobile: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
workspaceSession: getDefaultWorkspaceSession(),
|
||||
sshTargets: [],
|
||||
sshRemotePtyLeases: [],
|
||||
migrationUnsupportedPtyEntries: [],
|
||||
automations: [],
|
||||
automationRuns: [],
|
||||
onboarding: getDefaultOnboardingState()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue