mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Keep remote agent status in sync after completion and approvals (#34729)
* fix(remote): reconcile stale paired agent status and pane titles Adapt the remote completion repair from #25205 while preserving sparse runtime pane slots through existing PTY bindings. Co-authored-by: Manan Chataut <mananchataut@gmail.com> * fix(remote): preserve pane identity when reconciling moved titles Use mounted manager identities and retained PTY bindings ahead of replay order; keep fresh byte-stream ownership on the correct moved pane. * fix(remote): release byte status fence after host approval * fix(remote): tighten paired title removal and test the timing fence - Share the byte-write timing decision (resolveRendererOwnedAgentStatusTiming) between pty-connection and the flap test so reverting it fails a test. - Only delete a saved pane title when its durable leaf is absent from both the host layout and local panes; unresolved or surface-less slots are kept. - Share the client-fence predicate (clientAgentStatusFencesHost) between status and title reconciliation. - Cover removed-leaf spinner fallback in worktree status and a client split absent from a pre-split host snapshot. * fix(remote): correct fence predicate and title test types --------- Co-authored-by: Manan Chataut <mananchataut@gmail.com>
This commit is contained in:
+7
-3
@@ -26,7 +26,8 @@ import { isMainTerminalSideEffectAuthorityForPty } from '../terminal-side-effect
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from '../terminal-hidden-delivery-gate'
|
||||
import {
|
||||
markRendererOwnedAgentStatusWrite,
|
||||
registerRendererOwnedAgentStatusPane
|
||||
registerRendererOwnedAgentStatusPane,
|
||||
resolveRendererOwnedAgentStatusTiming
|
||||
} from '../renderer-owned-agent-status-registry'
|
||||
|
||||
import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at'
|
||||
@@ -138,12 +139,15 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
|
||||
: resolvedStatusTitle
|
||||
// Only actual byte-derived status proves authority to fence out host mirroring.
|
||||
markRendererOwnedAgentStatusWrite(session.cacheKey)
|
||||
const timing = resolveRendererOwnedAgentStatusTiming(
|
||||
currentState.agentStatusByPaneKey[session.cacheKey]
|
||||
)
|
||||
if (session.launchToken) {
|
||||
currentState.setAgentStatus(
|
||||
session.cacheKey,
|
||||
observedStatusPayload,
|
||||
statusTitle,
|
||||
undefined,
|
||||
timing,
|
||||
routing,
|
||||
{
|
||||
launchToken: session.launchToken
|
||||
@@ -154,7 +158,7 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
|
||||
session.cacheKey,
|
||||
observedStatusPayload,
|
||||
statusTitle,
|
||||
undefined,
|
||||
timing,
|
||||
routing
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
|
||||
/** Fence host mirroring only after renderer byte-derived status proves its claim; otherwise two writers flap the same pane row between working/generated titles and host defaults. */
|
||||
type RendererOwnedAgentStatusPane = {
|
||||
environmentId: string
|
||||
@@ -39,6 +41,15 @@ export function isClientAuthoritativeAgentStatusPane(paneKey: string): boolean {
|
||||
return panesByPaneKey.get(paneKey)?.hasClientWrite === true
|
||||
}
|
||||
|
||||
/** A byte write replacing an adopted host row cannot be ordered against the host's clock. */
|
||||
export function resolveRendererOwnedAgentStatusTiming(
|
||||
existing: Pick<AgentStatusEntry, 'mirroredEvidenceReceivedAt'> | undefined
|
||||
): { allowOlderTimestamp: true } | undefined {
|
||||
return existing?.mirroredEvidenceReceivedAt !== undefined
|
||||
? { allowOlderTimestamp: true }
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function _getRendererOwnedAgentStatusPaneCountForTest(): number {
|
||||
return panesByPaneKey.size
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorktreeStatus } from './worktree-status'
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_C = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('live numeric slots after closing an earlier split pane', () => {
|
||||
it('keeps a working background pane whose runtime id exceeds the remaining leaf count', () => {
|
||||
expect(
|
||||
getWorktreeStatus(
|
||||
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
|
||||
[],
|
||||
{ tab: ['pty-a', 'pty-c'] },
|
||||
{ tab: { 1: 'zsh', 3: '⠋ Codex' } },
|
||||
{
|
||||
terminalLayoutRootsByTabId: {
|
||||
tab: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: LEAF_A },
|
||||
second: { type: 'leaf', leafId: LEAF_C }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pane titles from removed panes', () => {
|
||||
it('ignores a removed durable leaf spinner and falls back to the idle tab title', () => {
|
||||
const removedLeafTitles = { [LEAF_C]: '⠋ Codex' }
|
||||
expect(
|
||||
getWorktreeStatus(
|
||||
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
|
||||
[],
|
||||
{ tab: ['pty-a'] },
|
||||
{ tab: removedLeafTitles },
|
||||
{ terminalLayoutRootsByTabId: { tab: { type: 'leaf', leafId: LEAF_A } } }
|
||||
)
|
||||
).not.toBe('working')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,11 @@
|
||||
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
|
||||
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
|
||||
import { tabHasLivePty } from '../../../shared/tab-has-live-pty'
|
||||
import { resolveRuntimePaneTitleLeafIdFromRoot } from '../../../shared/runtime-pane-title-leaf-id'
|
||||
import {
|
||||
collectRuntimePaneLeafIds,
|
||||
isRuntimePaneTitleOrphan,
|
||||
resolveRuntimePaneTitleLeafIdFromRoot
|
||||
} from '../../../shared/runtime-pane-title-leaf-id'
|
||||
import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core'
|
||||
import { isSyntheticAgentPermissionTitle } from '../../../shared/synthetic-agent-title'
|
||||
import type {
|
||||
@@ -81,12 +85,19 @@ function tabHasStatus(
|
||||
const tabLayoutRoot =
|
||||
options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root
|
||||
const paneTitleEntries = Object.entries(paneTitles)
|
||||
const layoutLeafIds = tabLayoutRoot ? new Set(collectRuntimePaneLeafIds(tabLayoutRoot)) : null
|
||||
let hasCurrentPaneTitle = false
|
||||
for (const [runtimePaneId, title] of paneTitleEntries) {
|
||||
const agentStatusPaneIds =
|
||||
status === 'permission' && isSyntheticAgentPermissionTitle(title)
|
||||
? permissionPaneIds
|
||||
: freshPaneIds
|
||||
const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId)
|
||||
// A removed pane's last title is no longer evidence for this tab.
|
||||
if (layoutLeafIds && isRuntimePaneTitleOrphan(runtimePaneId, layoutLeafIds)) {
|
||||
continue
|
||||
}
|
||||
hasCurrentPaneTitle = true
|
||||
// Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner.
|
||||
const hasSingleUnmappedAgentStatusPane =
|
||||
leafId === null && agentStatusPaneIds?.size === 1 && paneTitleEntries.length === 1
|
||||
@@ -104,7 +115,9 @@ function tabHasStatus(
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
if (hasCurrentPaneTitle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title.
|
||||
const agentStatusPaneIds =
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as WebRuntimeTerminalCreationModule from './web-runtime-terminal-creation'
|
||||
import type * as WebSessionTerminalHandleEventsModule from './web-session-terminal-handle-events'
|
||||
vi.mock('./web-session-terminal-handle-events', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WebSessionTerminalHandleEventsModule>()
|
||||
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
|
||||
return {
|
||||
...actual,
|
||||
queueAcceptedWebSessionTerminalSnapshot: frameOrderingMocks.queueAcceptedSnapshot
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./use-runtime-session-mirror-environment-key', async () => {
|
||||
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
|
||||
return {
|
||||
useRuntimeSessionMirrorEnvironmentKeys: () => ({
|
||||
environmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey(),
|
||||
resubscribeSignal: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./web-session-terminal-orphan-recovery', async () => {
|
||||
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
|
||||
return { recoverWebSessionTerminalOrphansBeforeApply: frameOrderingMocks.recoverSnapshot }
|
||||
})
|
||||
|
||||
vi.mock('./web-runtime-terminal-creation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WebRuntimeTerminalCreationModule>()
|
||||
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
|
||||
return { ...actual, createWebRuntimeSessionTerminal: frameOrderingMocks.createTerminal }
|
||||
})
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status'
|
||||
import {
|
||||
BG_MIRROR_TAB_ID,
|
||||
BG_WT,
|
||||
HOST_PARENT_TAB_ID,
|
||||
HOST_SURFACE_ID,
|
||||
LEAF_ID,
|
||||
MIRROR_TAB_ID,
|
||||
makeHostSnapshot,
|
||||
WT
|
||||
} from './host-session-mirror-frame-fixtures'
|
||||
import {
|
||||
findSubscription,
|
||||
installFrameOrderingHarness,
|
||||
publish,
|
||||
settle,
|
||||
setDocumentVisibility
|
||||
} from './host-session-mirror-frame-ordering-test-harness'
|
||||
import { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync'
|
||||
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking'
|
||||
|
||||
const NOW = 1_000_000
|
||||
|
||||
function agentSnapshot(
|
||||
worktree: string,
|
||||
parentTabId: string,
|
||||
surfaceId: string,
|
||||
state: 'working' | 'done'
|
||||
) {
|
||||
const snapshot = makeHostSnapshot(worktree, surfaceId, parentTabId)
|
||||
const tab = snapshot.tabs[0]
|
||||
if (tab?.type !== 'terminal') {
|
||||
throw new Error('Missing terminal fixture')
|
||||
}
|
||||
tab.title = state === 'done' ? 'Pi ready' : '⠋ Pi'
|
||||
tab.launchAgent = 'pi'
|
||||
tab.agentStatus = {
|
||||
paneKey: makePaneKey(parentTabId, LEAF_ID),
|
||||
state,
|
||||
agentType: 'pi',
|
||||
worktreeId: worktree,
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
stateHistory: [],
|
||||
prompt: ''
|
||||
}
|
||||
snapshot.snapshotVersion = state === 'done' ? 2 : 1
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function tabActivity(tabId: string, worktreeId: string) {
|
||||
const state = useAppStore.getState()
|
||||
const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
|
||||
if (!tab) {
|
||||
throw new Error('Missing mirrored terminal')
|
||||
}
|
||||
return resolveTerminalTabActivityStatus({
|
||||
tab,
|
||||
agentStatusByPaneKey: state.agentStatusByPaneKey,
|
||||
agentStatusEpoch: state.agentStatusEpoch,
|
||||
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
terminalLayout: state.terminalLayoutsByTabId[tabId]
|
||||
})
|
||||
}
|
||||
|
||||
describe('remote completion after the client window is hidden', () => {
|
||||
installFrameOrderingHarness({ fakeTimers: true })
|
||||
|
||||
it('resubscribes immediately on reveal and repairs completion in active and inactive workspaces', async () => {
|
||||
vi.setSystemTime(NOW)
|
||||
renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'snapshots',
|
||||
authoritative: true,
|
||||
snapshots: [
|
||||
agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'working'),
|
||||
agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'working')
|
||||
]
|
||||
})
|
||||
useAppStore.setState({
|
||||
runtimePaneTitlesByTabId: {
|
||||
[MIRROR_TAB_ID]: { 1: '⠋ Pi' },
|
||||
[BG_MIRROR_TAB_ID]: { 1: '⠋ Pi' }
|
||||
}
|
||||
})
|
||||
expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('working')
|
||||
expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('working')
|
||||
act(() => {
|
||||
setDocumentVisibility('hidden')
|
||||
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
authoritative: true,
|
||||
snapshots: [
|
||||
agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'done'),
|
||||
agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'done')
|
||||
]
|
||||
})
|
||||
expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('done')
|
||||
expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('done')
|
||||
expect(useAppStore.getState().runtimePaneTitlesByTabId).toMatchObject({
|
||||
[MIRROR_TAB_ID]: { 1: 'Pi ready' },
|
||||
[BG_MIRROR_TAB_ID]: { 1: 'Pi ready' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,8 @@ import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-
|
||||
import {
|
||||
markRendererOwnedAgentStatusWrite,
|
||||
registerRendererOwnedAgentStatusPane,
|
||||
resetRendererOwnedAgentStatusPanesForTests
|
||||
resetRendererOwnedAgentStatusPanesForTests,
|
||||
resolveRendererOwnedAgentStatusTiming
|
||||
} from '../components/terminal-pane/renderer-owned-agent-status-registry'
|
||||
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle'
|
||||
@@ -105,8 +106,7 @@ function applyHostSnapshot(
|
||||
store.setState(patch as Partial<AppState>)
|
||||
}
|
||||
|
||||
/** Replay paired pty-connection OSC handling with client Date.now(); return ownership release held until dispose. */
|
||||
/** Returns the pane's release, as pty-connection holds it for dispose(). */
|
||||
/** Replay paired pty-connection OSC handling with client Date.now(); returns the release pty-connection holds for dispose(). */
|
||||
function replayClientOscWorking(store: TestStore, clientNow: number): () => void {
|
||||
vi.setSystemTime(clientNow)
|
||||
// Claim on transport creation, then verify ownership on each byte-derived status.
|
||||
@@ -118,7 +118,7 @@ function replayClientOscWorking(store: TestStore, clientNow: number): () => void
|
||||
MIRROR_PANE_KEY,
|
||||
{ state: 'working', prompt: CLIENT_PROMPT, agentType: 'claude' },
|
||||
'claude',
|
||||
undefined,
|
||||
resolveRendererOwnedAgentStatusTiming(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]),
|
||||
{ tabId: MIRROR_TAB_ID, worktreeId: WT }
|
||||
)
|
||||
return release
|
||||
@@ -432,6 +432,118 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status
|
||||
expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?')
|
||||
})
|
||||
|
||||
it('follows behind-clock host continuation and completion after an HTTP approval', () => {
|
||||
const store = seedPairedClientStore()
|
||||
replayClientOscWorking(store, T0 + 500)
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 2,
|
||||
hostNow: T0 - 60_000,
|
||||
includeAgentStatus: true,
|
||||
agentStatusOverrides: {
|
||||
state: 'waiting',
|
||||
interactivePrompt: 'Allow exec_command?'
|
||||
}
|
||||
}),
|
||||
T0 + 1_000
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
|
||||
state: 'waiting',
|
||||
mirroredEvidenceReceivedAt: T0 + 1_000
|
||||
})
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 3,
|
||||
hostNow: T0 - 59_000,
|
||||
includeAgentStatus: true,
|
||||
agentStatusOverrides: { state: 'working' }
|
||||
}),
|
||||
T0 + 2_000
|
||||
)
|
||||
const continued = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
|
||||
expect(continued?.state).toBe('working')
|
||||
expect(continued?.interactivePrompt).toBeUndefined()
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 4,
|
||||
hostNow: T0 - 58_000,
|
||||
includeAgentStatus: true
|
||||
}),
|
||||
T0 + 3_000
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('done')
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 5,
|
||||
hostNow: T0 - 57_000,
|
||||
includeAgentStatus: false
|
||||
}),
|
||||
T0 + 4_000
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reclaims byte ownership after approval despite an ahead-clock host', () => {
|
||||
const store = seedPairedClientStore()
|
||||
replayClientOscWorking(store, T0 + 500)
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 2,
|
||||
hostNow: T0 + 60_000,
|
||||
includeAgentStatus: true,
|
||||
agentStatusOverrides: {
|
||||
state: 'waiting',
|
||||
interactivePrompt: 'Allow exec_command?'
|
||||
}
|
||||
}),
|
||||
T0 + 1_000
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('waiting')
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 3,
|
||||
hostNow: T0 + 61_000,
|
||||
includeAgentStatus: true,
|
||||
agentStatusOverrides: { state: 'working' }
|
||||
}),
|
||||
T0 + 1_250
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working')
|
||||
|
||||
replayClientOscWorking(store, T0 + 1_500)
|
||||
const byteEntry = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
|
||||
expect(byteEntry?.state).toBe('working')
|
||||
expect(byteEntry?.mirroredEvidenceReceivedAt).toBeUndefined()
|
||||
expect(byteEntry?.interactivePrompt).toBeUndefined()
|
||||
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 4,
|
||||
hostNow: T0 + 120_000,
|
||||
includeAgentStatus: true
|
||||
}),
|
||||
T0 + 2_000
|
||||
)
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
|
||||
state: 'working',
|
||||
updatedAt: T0 + 1_500,
|
||||
prompt: CLIENT_PROMPT
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps live client status over a restored, unconfirmed host block', () => {
|
||||
const store = seedPairedClientStore()
|
||||
replayClientOscWorking(store, T0 + 500)
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTerminalClientTab } from '../../../shared/runtime-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status'
|
||||
import {
|
||||
markRendererOwnedAgentStatusWrite,
|
||||
registerRendererOwnedAgentStatusPane,
|
||||
resetRendererOwnedAgentStatusPanesForTests
|
||||
} from '../components/terminal-pane/renderer-owned-agent-status-registry'
|
||||
import {
|
||||
applyWebSessionTabsSnapshot,
|
||||
applyWebSessionTabsSnapshots
|
||||
} from './web-session-tabs-sync/snapshot-api'
|
||||
import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { registerRuntimeTerminalTab } from './mounted-terminal-tab-registry'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import {
|
||||
ENV,
|
||||
HOST_SURFACE_ID,
|
||||
LEAF_ID,
|
||||
NOW,
|
||||
SECOND_LEAF_ID,
|
||||
THIRD_LEAF_ID,
|
||||
WT,
|
||||
makeSnapshot,
|
||||
makeState,
|
||||
resetWebSessionTabsSyncTestState
|
||||
} from './web-session-tabs-sync-test-harness'
|
||||
|
||||
vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } }))
|
||||
|
||||
const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1')
|
||||
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
|
||||
|
||||
function hostPane(state: 'done' | 'working' = 'done'): RuntimeMobileSessionTerminalClientTab & {
|
||||
status: 'ready'
|
||||
terminal: string
|
||||
} {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
title: state === 'done' ? 'Pi ready' : '⠋ Pi',
|
||||
launchAgent: 'pi',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-1',
|
||||
agentStatus: {
|
||||
state,
|
||||
agentType: 'pi',
|
||||
paneKey: makePaneKey('host-tab-1', LEAF_ID),
|
||||
worktreeId: WT,
|
||||
prompt: '',
|
||||
updatedAt: state === 'working' ? NOW - 1_000 : NOW,
|
||||
stateStartedAt: state === 'working' ? NOW - 1_000 : NOW,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restoredState(): WebSessionTabsSyncState & {
|
||||
runtimePaneTitlesByTabId: NonNullable<WebSessionTabsSyncState['runtimePaneTitlesByTabId']>
|
||||
} {
|
||||
const initial = makeState()
|
||||
const state = {
|
||||
...initial,
|
||||
...applyWebSessionTabsSnapshot(initial, makeSnapshot([hostPane('working')]), ENV, NOW)
|
||||
}
|
||||
const row = state.agentStatusByPaneKey[PANE_KEY]
|
||||
if (!row) {
|
||||
throw new Error('Missing mirrored status')
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activeWorktreeId: 'another-workspace',
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - saved session' } },
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: { ...row, mirroredEvidenceReceivedAt: NOW - 55 * 60 * 60 * 1000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSessionTabsSyncTestState()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
afterEach(() => {
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('remote completion repairs saved spinner titles', () => {
|
||||
it('refreshes numeric runtime slots in an inactive workspace and removes the false spinner', () => {
|
||||
const state = restoredState()
|
||||
const updated = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
}
|
||||
expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready' })
|
||||
expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: '⠋ Pi - saved session' })
|
||||
expect(updated.agentStatusByPaneKey[PANE_KEY]?.state).toBe('done')
|
||||
const tab = updated.tabsByWorktree[WT]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error('Missing mirrored tab')
|
||||
}
|
||||
expect(
|
||||
resolveTerminalTabActivityStatus({
|
||||
tab,
|
||||
runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId,
|
||||
agentStatusByPaneKey: updated.agentStatusByPaneKey,
|
||||
agentStatusEpoch: updated.agentStatusEpoch,
|
||||
ptyIdsByTabId: updated.ptyIdsByTabId,
|
||||
terminalLayout: updated.terminalLayoutsByTabId[TAB_ID]
|
||||
})
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('drops absent durable leaves while refreshing a sparse numeric slot', () => {
|
||||
const state = restoredState()
|
||||
const orphanTitles = { 2: '⠋ Pi', [SECOND_LEAF_ID]: '⠋ Pi' }
|
||||
state.runtimePaneTitlesByTabId[TAB_ID] = orphanTitles
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 2: 'Pi ready' })
|
||||
})
|
||||
|
||||
it('accepts host done over an expired row even when its clock is behind the client row', () => {
|
||||
const state = restoredState()
|
||||
const row = state.agentStatusByPaneKey[PANE_KEY]
|
||||
if (!row) {
|
||||
throw new Error('Missing mirrored status')
|
||||
}
|
||||
state.agentStatusByPaneKey[PANE_KEY] = { ...row, updatedAt: NOW + 60_000 }
|
||||
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
|
||||
markRendererOwnedAgentStatusWrite(PANE_KEY)
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.agentStatusByPaneKey?.[PANE_KEY]).toMatchObject({ state: 'done', updatedAt: NOW })
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' })
|
||||
})
|
||||
|
||||
it('keeps a fresh byte-stream writer and its title during a lagging host publication', () => {
|
||||
const state = restoredState()
|
||||
const row = state.agentStatusByPaneKey[PANE_KEY]
|
||||
if (!row) {
|
||||
throw new Error('Missing mirrored status')
|
||||
}
|
||||
state.agentStatusByPaneKey[PANE_KEY] = {
|
||||
...row,
|
||||
updatedAt: NOW,
|
||||
mirroredEvidenceReceivedAt: undefined
|
||||
}
|
||||
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
|
||||
markRendererOwnedAgentStatusWrite(PANE_KEY)
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
|
||||
const nextStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey
|
||||
expect(nextStatuses[PANE_KEY]?.state).toBe('working')
|
||||
})
|
||||
|
||||
it('preserves title repairs across a batched all-workspace inventory', () => {
|
||||
const state = restoredState()
|
||||
const patch = applyWebSessionTabsSnapshots(
|
||||
state,
|
||||
[
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
makeSnapshot([], { worktree: 'another-workspace' })
|
||||
],
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' })
|
||||
expect(patch.agentStatusByPaneKey?.[PANE_KEY]?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps a genuinely working sibling spinning when another split pane completes', () => {
|
||||
const state = restoredState()
|
||||
const sibling = {
|
||||
...hostPane('working'),
|
||||
id: `host-tab-1::${SECOND_LEAF_ID}`,
|
||||
leafId: SECOND_LEAF_ID,
|
||||
isActive: false,
|
||||
terminal: 'terminal-2'
|
||||
}
|
||||
state.runtimePaneTitlesByTabId[TAB_ID] = { 1: '⠋ Pi', 2: '⠋ Pi' }
|
||||
const updated = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
}
|
||||
const tab = updated.tabsByWorktree[WT]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error('Missing mirrored tab')
|
||||
}
|
||||
expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready', 2: '⠋ Pi' })
|
||||
expect(
|
||||
resolveTerminalTabActivityStatus({
|
||||
tab,
|
||||
agentStatusByPaneKey: updated.agentStatusByPaneKey,
|
||||
agentStatusEpoch: updated.agentStatusEpoch,
|
||||
runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId: updated.ptyIdsByTabId,
|
||||
terminalLayout: updated.terminalLayoutsByTabId[TAB_ID]
|
||||
})
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
it('refreshes sparse runtime slots through their retained PTY bindings after a pane closes', () => {
|
||||
const sibling = {
|
||||
...hostPane('working'),
|
||||
id: `host-tab-1::${SECOND_LEAF_ID}`,
|
||||
leafId: SECOND_LEAF_ID,
|
||||
isActive: false,
|
||||
terminal: 'terminal-2'
|
||||
}
|
||||
const initial = makeState()
|
||||
const state = {
|
||||
...initial,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
initial,
|
||||
makeSnapshot([hostPane('working'), sibling]),
|
||||
ENV,
|
||||
NOW
|
||||
),
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 3: '⠋ Pi - sibling' } }
|
||||
}
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready', 3: '⠋ Pi' })
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps moved pane titles attached to their PTY with a fresh writer: %s',
|
||||
(freshFirstWriter) => {
|
||||
const sibling = {
|
||||
...hostPane(),
|
||||
id: `host-tab-1::${SECOND_LEAF_ID}`,
|
||||
leafId: SECOND_LEAF_ID,
|
||||
isActive: false,
|
||||
terminal: 'terminal-2',
|
||||
title: 'Pi second ready'
|
||||
}
|
||||
const initial = makeState()
|
||||
const mirrored = {
|
||||
...initial,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
initial,
|
||||
makeSnapshot([hostPane('working'), sibling]),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
}
|
||||
const layout: TerminalLayoutSnapshot = {
|
||||
...mirrored.terminalLayoutsByTabId[TAB_ID],
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: SECOND_LEAF_ID },
|
||||
second: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
}
|
||||
const state = {
|
||||
...mirrored,
|
||||
terminalLayoutsByTabId: { [TAB_ID]: layout },
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 2: '⠋ Pi - second' } }
|
||||
}
|
||||
if (freshFirstWriter) {
|
||||
const row = state.agentStatusByPaneKey[PANE_KEY]
|
||||
if (!row) {
|
||||
throw new Error('Missing mirrored status')
|
||||
}
|
||||
state.agentStatusByPaneKey[PANE_KEY] = {
|
||||
...row,
|
||||
mirroredEvidenceReceivedAt: undefined
|
||||
}
|
||||
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
|
||||
markRendererOwnedAgentStatusWrite(PANE_KEY)
|
||||
}
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({
|
||||
1: freshFirstWriter ? '⠋ Pi - first' : 'Pi ready',
|
||||
2: 'Pi second ready'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('retains an unmapped numeric title until its pane ownership is known', () => {
|
||||
const state = restoredState()
|
||||
state.runtimePaneTitlesByTabId[TAB_ID] = { 3: '⠋ Pi', 7: '⠋ Pi' }
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
|
||||
expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 3: '⠋ Pi', 7: '⠋ Pi' })
|
||||
})
|
||||
|
||||
it('keeps titles of a client split the pre-split host snapshot does not have yet', () => {
|
||||
const leafIdsBySlot = new Map([
|
||||
[1, LEAF_ID],
|
||||
[2, THIRD_LEAF_ID]
|
||||
])
|
||||
const manager = {
|
||||
getLeafId: (paneId: number) => leafIdsBySlot.get(paneId) ?? null,
|
||||
getNumericIdForLeaf: (leafId: string) =>
|
||||
[...leafIdsBySlot].find(([, id]) => id === leafId)?.[0] ?? null
|
||||
}
|
||||
const unregister = registerRuntimeTerminalTab({
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WT,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the title patch reads only getLeafId/getNumericIdForLeaf.
|
||||
getManager: () => manager as unknown as PaneManager,
|
||||
getContainer: () => null,
|
||||
getPtyIdForPane: () => null,
|
||||
getTabWideAgentHintLeafId: () => null
|
||||
})
|
||||
try {
|
||||
const state = restoredState()
|
||||
const clientSplitTitles = {
|
||||
1: '⠋ Pi - saved session',
|
||||
2: '⠋ Pi',
|
||||
[THIRD_LEAF_ID]: '⠋ Pi'
|
||||
}
|
||||
state.runtimePaneTitlesByTabId[TAB_ID] = clientSplitTitles
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({
|
||||
1: 'Pi ready',
|
||||
2: '⠋ Pi',
|
||||
[THIRD_LEAF_ID]: '⠋ Pi'
|
||||
})
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not interpret a pending host placeholder as new title evidence', () => {
|
||||
const state = restoredState()
|
||||
const pending = {
|
||||
...hostPane(),
|
||||
title: 'Terminal',
|
||||
status: 'pending-handle' as const,
|
||||
terminal: null,
|
||||
agentStatus: undefined
|
||||
}
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([pending], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not rewrite local terminal runtime titles from a local session mirror', () => {
|
||||
const state = restoredState()
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW,
|
||||
{ terminalPtyMode: 'local' }
|
||||
)
|
||||
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores orphan titles before host sync and falls back to the current tab title', () => {
|
||||
const state = restoredState()
|
||||
const tab = state.tabsByWorktree[WT]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error('Missing mirrored tab')
|
||||
}
|
||||
const orphanTitles = { [SECOND_LEAF_ID]: '⠋ Pi' }
|
||||
const readyOrphanTitles = { [SECOND_LEAF_ID]: 'Pi ready' }
|
||||
expect(
|
||||
resolveTerminalTabActivityStatus({
|
||||
tab: { ...tab, title: 'Pi ready' },
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: orphanTitles },
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
terminalLayout: state.terminalLayoutsByTabId[TAB_ID]
|
||||
})
|
||||
).toBe('active')
|
||||
expect(
|
||||
resolveTerminalTabActivityStatus({
|
||||
tab: { ...tab, title: '⠋ Pi' },
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: readyOrphanTitles },
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
terminalLayout: state.terminalLayoutsByTabId[TAB_ID]
|
||||
})
|
||||
).toBe('working')
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import {
|
||||
isClientOwnedAgentStatus,
|
||||
isMirroredAgentStatusOwnedBy,
|
||||
isFencedClientAgentStatus,
|
||||
clientAgentStatusFencesHost,
|
||||
hostAgentStatusPiercesClientAuthority,
|
||||
isMirroredAgentPaneKeyForTabs,
|
||||
batchAgentPaneKeysForTabs,
|
||||
@@ -93,11 +93,14 @@ export function buildMirroredAgentStatusPatch(
|
||||
existing.state !== 'done' &&
|
||||
existing.stateStartedAt > entry.stateStartedAt
|
||||
// Cross-machine clocks cannot arbitrate live byte ownership; keep client state except host-only classes, adopting host identity below.
|
||||
const clientOwnsEntry =
|
||||
isFencedClientAgentStatus(entry.paneKey, existing, now) &&
|
||||
!hostAgentStatusPiercesClientAuthority(entry)
|
||||
const hostPiercesAuthority = hostAgentStatusPiercesClientAuthority(entry)
|
||||
const clientOwnsEntry = clientAgentStatusFencesHost(entry.paneKey, existing, entry, now)
|
||||
const nextEntry =
|
||||
existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt)
|
||||
existing &&
|
||||
(clientOwnsEntry ||
|
||||
(!hostPiercesAuthority &&
|
||||
isAgentStatusFresh(existing, now) &&
|
||||
existing.updatedAt > entry.updatedAt))
|
||||
? {
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
|
||||
...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working'
|
||||
|
||||
@@ -72,12 +72,16 @@ export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry):
|
||||
)
|
||||
}
|
||||
|
||||
/** Byte-derived ownership requires transport claim plus a write; teardown releases it back to host. */
|
||||
/** Only the current byte-derived row can fence host snapshots; adopting a host row yields authority. */
|
||||
export function isClientOwnedAgentStatus(
|
||||
paneKey: string,
|
||||
existing: AgentStatusEntry | undefined
|
||||
): existing is AgentStatusEntry {
|
||||
return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey)
|
||||
return (
|
||||
existing !== undefined &&
|
||||
existing.mirroredEvidenceReceivedAt === undefined &&
|
||||
isClientAuthoritativeAgentStatusPane(paneKey)
|
||||
)
|
||||
}
|
||||
|
||||
/** When host and client compete, stale client status yields to host; ownership alone still protects against host omission. */
|
||||
@@ -89,6 +93,19 @@ export function isFencedClientAgentStatus(
|
||||
return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now)
|
||||
}
|
||||
|
||||
/** A fresh client byte writer keeps the pane unless the host carries a host-only state class. */
|
||||
export function clientAgentStatusFencesHost(
|
||||
paneKey: string,
|
||||
existing: AgentStatusEntry | undefined,
|
||||
hostEntry: AgentStatusEntry | null | undefined,
|
||||
now: number
|
||||
): boolean {
|
||||
return (
|
||||
isFencedClientAgentStatus(paneKey, existing, now) &&
|
||||
!(hostEntry && hostAgentStatusPiercesClientAuthority(hostEntry))
|
||||
)
|
||||
}
|
||||
|
||||
export function batchAgentPaneKeysForTabs(
|
||||
state: WebSessionTabsSyncState,
|
||||
tabIds: ReadonlySet<string>,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership'
|
||||
import type { WebSessionTabsSyncState } from './state'
|
||||
import type { applyActiveStateUpdates } from './apply-active-state'
|
||||
import { buildMirroredAgentStatusPatch } from './agent-status-patch'
|
||||
import { buildMirroredRuntimePaneTitlePatch } from './runtime-pane-title-patch'
|
||||
import {
|
||||
buildRemirroredClosedTabMarkerLiftPatch,
|
||||
buildRetractedMirroredTabSweepPatch
|
||||
@@ -65,6 +66,16 @@ export function buildWebSessionTabsFinalPatch(
|
||||
worktreeId,
|
||||
batchContext
|
||||
)
|
||||
const runtimePaneTitlePatch =
|
||||
context.options?.terminalPtyMode === 'local'
|
||||
? null
|
||||
: buildMirroredRuntimePaneTitlePatch(
|
||||
state,
|
||||
mirroredTerminalTabs,
|
||||
terminalSurfaceTabs,
|
||||
now,
|
||||
batchContext
|
||||
)
|
||||
const agentStatusPatch = buildMirroredAgentStatusPatch(
|
||||
state,
|
||||
currentTerminalTabs,
|
||||
@@ -107,6 +118,7 @@ export function buildWebSessionTabsFinalPatch(
|
||||
)
|
||||
|
||||
const patch: Partial<WebSessionTabsSyncState> = {
|
||||
...runtimePaneTitlePatch,
|
||||
...agentStatusPatch,
|
||||
...retractedTabSweepPatch,
|
||||
...remirroredClosedTabLiftPatch,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
collectRuntimePaneLeafIds,
|
||||
isRuntimePaneTitleOrphan,
|
||||
resolveRuntimePaneTitleLeafId,
|
||||
resolveRuntimePaneTitleLeafIdFromSparseSlots
|
||||
} from '../../../../shared/runtime-pane-title-leaf-id'
|
||||
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner'
|
||||
import { findRegisteredTerminalTab } from '../mounted-terminal-tab-registry'
|
||||
import { clientAgentStatusFencesHost } from './agent-status-primitives'
|
||||
import { writableWebSessionTabsRecord } from './state-equality-core'
|
||||
import type {
|
||||
MirroredTerminalTab,
|
||||
TerminalSurface,
|
||||
WebSessionTabsBatchContext,
|
||||
WebSessionTabsSyncState
|
||||
} from './state'
|
||||
|
||||
/** Reconcile saved pane titles with the accepted host inventory, including inactive tabs. */
|
||||
export function buildMirroredRuntimePaneTitlePatch(
|
||||
state: WebSessionTabsSyncState,
|
||||
tabs: readonly MirroredTerminalTab[],
|
||||
surfaces: readonly TerminalSurface[],
|
||||
now: number,
|
||||
batchContext?: WebSessionTabsBatchContext
|
||||
): Partial<WebSessionTabsSyncState> | null {
|
||||
const titles = state.runtimePaneTitlesByTabId
|
||||
if (!titles) {
|
||||
return null
|
||||
}
|
||||
const titledTabs = tabs.flatMap((entry) => {
|
||||
const paneTitles = titles[entry.tab.id]
|
||||
return paneTitles ? [{ ...entry, paneTitles }] : []
|
||||
})
|
||||
if (titledTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const surfacesByTab = new Map<string, Map<string, TerminalSurface>>()
|
||||
for (const surface of surfaces) {
|
||||
const byLeaf = surfacesByTab.get(surface.parentTabId) ?? new Map<string, TerminalSurface>()
|
||||
byLeaf.set(surface.leafId, surface)
|
||||
surfacesByTab.set(surface.parentTabId, byLeaf)
|
||||
}
|
||||
let nextTitles = titles
|
||||
for (const { tab, hostTabId, layout, retainedSurfaceByPrunedLeafId, paneTitles } of titledTabs) {
|
||||
const leaves = new Set(collectRuntimePaneLeafIds(layout.root))
|
||||
const liveSlotIds = Object.keys(paneTitles)
|
||||
.map(Number)
|
||||
.filter((id) => Number.isInteger(id) && id >= FIRST_PANE_ID)
|
||||
.sort((a, b) => a - b)
|
||||
const liveSlotsAreDense =
|
||||
liveSlotIds.length === leaves.size &&
|
||||
liveSlotIds.every((id, index) => id === FIRST_PANE_ID + index)
|
||||
const manager = findRegisteredTerminalTab(tab.id, tab.worktreeId)?.tab.getManager()
|
||||
const hostSurfacesByLeaf = new Map(surfacesByTab.get(hostTabId))
|
||||
for (const [hostLeafId, retainedSurface] of retainedSurfaceByPrunedLeafId ?? []) {
|
||||
const surface = hostSurfacesByLeaf.get(hostLeafId)
|
||||
if (surface) {
|
||||
hostSurfacesByLeaf.set(retainedSurface.leafId, surface)
|
||||
}
|
||||
}
|
||||
let nextPaneTitles: Record<string, string> = paneTitles
|
||||
for (const [runtimePaneId, title] of Object.entries(paneTitles)) {
|
||||
const leafId = isTerminalLeafId(runtimePaneId)
|
||||
? runtimePaneId
|
||||
: manager
|
||||
? manager.getLeafId(Number(runtimePaneId))
|
||||
: (resolveRuntimePaneTitleLeafIdFromSparseSlots({
|
||||
layout: state.terminalLayoutsByTabId[tab.id],
|
||||
paneId: Number(runtimePaneId),
|
||||
liveSlotIds,
|
||||
ptyIds: state.ptyIdsByTabId[tab.id] ?? []
|
||||
}) ?? (liveSlotsAreDense ? resolveRuntimePaneTitleLeafId(layout, runtimePaneId) : null))
|
||||
// Why: a slot the layout cannot place is not proof the pane was removed.
|
||||
if (leafId === null) {
|
||||
continue
|
||||
}
|
||||
const hostSurface = leaves.has(leafId) ? hostSurfacesByLeaf.get(leafId) : undefined
|
||||
let hostTitle: string | undefined
|
||||
if (hostSurface) {
|
||||
// A pending handle's placeholder carries no new title evidence.
|
||||
if (
|
||||
!hostSurface.title.trim() ||
|
||||
(hostSurface.status === 'pending-handle' && hostSurface.title === 'Terminal')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const paneKey = makePaneKey(tab.id, leafId)
|
||||
// Keep the existing byte-stream ownership fence while that writer is fresh.
|
||||
if (
|
||||
clientAgentStatusFencesHost(
|
||||
paneKey,
|
||||
state.agentStatusByPaneKey[paneKey],
|
||||
hostSurface.agentStatus,
|
||||
now
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const owner = resolvePaneAgentOwnerRecord({
|
||||
launchAgent: hostSurface.launchAgent ?? tab.launchAgent,
|
||||
hookAgent: hostSurface.agentStatus?.agentType
|
||||
})
|
||||
hostTitle = normalizeCompatibleAgentTitleForOwner(hostSurface.title, owner?.agent, {
|
||||
ownerIsLaunch: owner?.ownerIsLaunch === true
|
||||
})
|
||||
if (title === hostTitle) {
|
||||
continue
|
||||
}
|
||||
} else if (
|
||||
!isRuntimePaneTitleOrphan(runtimePaneId, leaves) ||
|
||||
manager?.getNumericIdForLeaf(runtimePaneId) != null
|
||||
) {
|
||||
// Only a durable leaf absent from both the host layout and local panes proves removal.
|
||||
continue
|
||||
}
|
||||
if (nextPaneTitles === paneTitles) {
|
||||
nextPaneTitles = { ...paneTitles }
|
||||
}
|
||||
// Runtime slots remain numeric until the pane manager migrates; refresh their values in place.
|
||||
if (hostTitle !== undefined) {
|
||||
nextPaneTitles[runtimePaneId] = hostTitle
|
||||
} else {
|
||||
delete nextPaneTitles[runtimePaneId]
|
||||
}
|
||||
}
|
||||
if (nextPaneTitles === paneTitles) {
|
||||
continue
|
||||
}
|
||||
if (nextTitles === titles) {
|
||||
nextTitles = writableWebSessionTabsRecord(state, 'runtimePaneTitlesByTabId', batchContext)
|
||||
}
|
||||
nextTitles[tab.id] = nextPaneTitles
|
||||
}
|
||||
return nextTitles === titles ? null : { runtimePaneTitlesByTabId: nextTitles }
|
||||
}
|
||||
@@ -245,6 +245,7 @@ export type WebSessionTabsSyncState = Pick<
|
||||
| 'recentlyRetiredAgentStatusPaneKeys'
|
||||
| 'retainedAgentsByPaneKey'
|
||||
| 'retentionSuppressedPaneKeys'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -265,6 +266,7 @@ export type WebSessionTabsBatchRecordKey =
|
||||
| 'pendingStartupByTabId'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'remoteBrowserPageHandlesByPageId'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
| 'tabBarOrderByWorktree'
|
||||
| 'tabsByWorktree'
|
||||
| 'terminalLayoutsByTabId'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from './terminal-tab-types'
|
||||
import {
|
||||
isRuntimePaneTitleOrphan,
|
||||
resolveRuntimePaneTitleForLeaf,
|
||||
resolveRuntimePaneTitleLeafResolution
|
||||
} from './runtime-pane-title-leaf-id'
|
||||
@@ -60,3 +61,16 @@ describe('resolveRuntimePaneTitleLeafResolution', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRuntimePaneTitleOrphan', () => {
|
||||
it('never treats numeric slots as removed panes', () => {
|
||||
for (const runtimePaneId of ['0', '-1', '2', '3']) {
|
||||
expect(isRuntimePaneTitleOrphan(runtimePaneId, new Set([LEAF_A]))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('flags only a durable leaf id the layout no longer has', () => {
|
||||
expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A, LEAF_B]))).toBe(false)
|
||||
expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A]))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,6 +125,14 @@ export function resolveRuntimePaneTitleLeafIdFromRoot(
|
||||
return leafIds[numericPaneId - FIRST_PANE_ID] ?? null
|
||||
}
|
||||
|
||||
/** Numeric slots survive pane closes with gaps, so only absent durable leaf ids prove removal. */
|
||||
export function isRuntimePaneTitleOrphan(
|
||||
runtimePaneId: string,
|
||||
layoutLeafIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
return isTerminalLeafId(runtimePaneId) && !layoutLeafIds.has(runtimePaneId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a sparse live runtime pane slot through the tab's current PTY bindings.
|
||||
* PaneManager ids survive closes with gaps, while the live PTY list retains order.
|
||||
|
||||
Reference in New Issue
Block a user