refactor(renderer): split terminal store slice

This commit is contained in:
Neil
2026-08-23 17:31:06 -07:00
parent 0a613d5fed
commit cae22817dc
41 changed files with 5245 additions and 4299 deletions
-1
View File
@@ -154,7 +154,6 @@ inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/web/web-preload-api.ts
@@ -1,5 +1,5 @@
import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state'
import type { CodexRestartNotice } from '@/store/slices/terminals'
import type { CodexRestartNotice } from '@/store/terminals/terminal-contracts'
type TabLookup = Record<string, { id: string }[]>
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { isShellProcess } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import {
@@ -1,6 +1,6 @@
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { CodexRestartNotice } from './terminals'
import type { CodexRestartNotice } from '../terminals/terminal-contracts'
export type DirectSshPaneRetryAttemptId = string & {
readonly __directSshPaneRetryAttemptId: unique symbol
@@ -1,6 +1,6 @@
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { CodexRestartNotice } from './terminals'
import type { CodexRestartNotice } from '../terminals/terminal-contracts'
import {
directSshAuthoritiesEqual,
liveBindingMatches,
@@ -5,6 +5,7 @@ import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test
import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { createStoreSessionMockApi } from './store-session-test-harness'
import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -458,4 +459,190 @@ describe('reconnectPersistedTerminals', () => {
})
).toEqual([])
})
it('leaves reconnect state untouched when cancellation is already signaled', async () => {
const store = createDaemonEnabledStore()
const worktreeId = 'repo1::/path/wt1'
const tab = makeTab({ id: 'tab1', worktreeId, ptyId: null })
store.setState({
tabsByWorktree: { [worktreeId]: [tab] },
ptyIdsByTabId: { tab1: [] },
pendingReconnectWorktreeIds: [worktreeId],
pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] },
pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' },
deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' },
workspaceSessionReady: false
})
const before = store.getState()
const controller = new AbortController()
controller.abort()
await store.getState().reconnectPersistedTerminals(controller.signal)
const after = store.getState()
expect(after.tabsByWorktree).toBe(before.tabsByWorktree)
expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId)
expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds)
expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree)
expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId)
expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId)
expect(after.workspaceSessionReady).toBe(false)
})
it('leaves reconnect state untouched when cancellation arrives before final publication', async () => {
const store = createDaemonEnabledStore()
const worktreeId = 'repo1::/path/wt1'
store.setState({
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })]
},
ptyIdsByTabId: { tab1: [] },
pendingReconnectWorktreeIds: [worktreeId],
pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] },
pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' },
deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' },
workspaceSessionReady: false
})
const before = store.getState()
const controller = new AbortController()
let abortReads = 0
Object.defineProperty(controller.signal, 'aborted', {
configurable: true,
get: () => {
abortReads += 1
return abortReads > 1
}
})
await store.getState().reconnectPersistedTerminals(controller.signal)
const after = store.getState()
expect(abortReads).toBe(2)
expect(after.tabsByWorktree).toBe(before.tabsByWorktree)
expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId)
expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds)
expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree)
expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId)
expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId)
expect(after.workspaceSessionReady).toBe(false)
})
it('leaves scoped reconnect state untouched when direct SSH authority is stale at entry', async () => {
const store = createDaemonEnabledStore()
const targetId = 'ssh-1'
const worktreeId = 'repo1::/remote/wt1'
const staleAuthority: DirectSshAuthority = {
targetId,
providerEpoch: 'epoch-1' as SshProviderEpoch,
connectionGeneration: 1
}
store.setState({
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })]
},
ptyIdsByTabId: { tab1: [] },
pendingReconnectWorktreeIds: [worktreeId],
pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] },
pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' },
deferredSshSessionIdsByTabId: { tab1: 'ssh:ssh-1@@deferred-1' },
sshConnectionStates: new Map([
[
targetId,
{
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0,
providerEpoch: staleAuthority.providerEpoch,
connectionGeneration: 2
}
]
]),
workspaceSessionReady: false
})
const before = store.getState()
await store.getState().reconnectPersistedTerminals(undefined, {
directSshAuthority: staleAuthority,
workspaceKeys: [worktreeId]
})
const after = store.getState()
expect(after.tabsByWorktree).toBe(before.tabsByWorktree)
expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId)
expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds)
expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree)
expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId)
expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId)
expect(after.workspaceSessionReady).toBe(before.workspaceSessionReady)
})
it('leaves scoped reconnect state untouched when direct SSH authority rotates before publication', async () => {
const store = createDaemonEnabledStore()
const targetId = 'ssh-1'
const worktreeId = 'repo1::/remote/wt1'
const authority: DirectSshAuthority = {
targetId,
providerEpoch: 'epoch-1' as SshProviderEpoch,
connectionGeneration: 1
}
let authorityReads = 0
const currentAuthorityState = {
targetId,
status: 'connected' as const,
error: null,
reconnectAttempt: 0,
providerEpoch: authority.providerEpoch,
connectionGeneration: authority.connectionGeneration
}
const rotatedAuthorityState = {
...currentAuthorityState,
connectionGeneration: 2
}
const sshConnectionStates = new Map([[targetId, currentAuthorityState]])
const originalGet = sshConnectionStates.get.bind(sshConnectionStates)
sshConnectionStates.get = ((key: string) => {
authorityReads += 1
return authorityReads >= 4 ? rotatedAuthorityState : originalGet(key)
}) as typeof sshConnectionStates.get
store.setState({
repos: [
{
id: 'repo1',
path: '/repo1',
displayName: 'Repo 1',
badgeColor: '#000',
addedAt: 0,
connectionId: targetId
}
],
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/remote/wt1' })]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })]
},
ptyIdsByTabId: { tab1: [] },
pendingReconnectWorktreeIds: [worktreeId],
pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] },
pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' },
deferredSshSessionIdsByTabId: {},
sshConnectionStates,
workspaceSessionReady: false
})
const before = store.getState()
await store.getState().reconnectPersistedTerminals(undefined, {
directSshAuthority: authority,
workspaceKeys: [worktreeId]
})
const after = store.getState()
expect(authorityReads).toBeGreaterThanOrEqual(4)
expect(after.tabsByWorktree).toBe(before.tabsByWorktree)
expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId)
expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds)
expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree)
expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId)
expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId)
expect(after.workspaceSessionReady).toBe(false)
})
})
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { getDefaultSettings } from '../../../../shared/constants'
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture'
import { createTestStore, makeRuntimeOwnedWorktree, makeTab, seedStore } from './store-test-helpers'
import {
applySleepRuntimeRpcDefault,
createStoreCascadesMockApi
} from './store-cascades-test-harness'
const orderHarness = vi.hoisted(() => {
const events: string[] = []
return {
events,
unregister: vi.fn(() => [
{
ptyId: 'pty-1',
commit: () => events.push('handler-commit'),
rollback: vi.fn()
}
]),
settleDeferred: vi.fn(() => events.push('deferred-exit-settlement'))
}
})
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers: orderHarness.unregister
}))
vi.mock('@/components/terminal-pane/pty-shutdown-exit-deferral', () => ({
clearCommittedPtyShutdownSettlements: vi.fn(),
hasCommittedPtyShutdownSettlement: vi.fn(() => false),
markCommittedPtyShutdowns: vi.fn(),
noteCommittedPtyShutdownSettlements: vi.fn(),
settleDeferredPtyShutdownExits: orderHarness.settleDeferred
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
const mockApi = createStoreCascadesMockApi()
describe('shutdownWorktreeTerminals ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
orderHarness.events.length = 0
shutdownBufferCaptures.clear()
applySleepRuntimeRpcDefault(mockApi)
})
it('orders capture, guards, handlers, owner stop, renderer kill, commit, cleanup, and exit settlement', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeRuntimeOwnedWorktree({ id: worktreeId, repoId: 'repo1' })]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: 'pty-1' })]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
dropAgentStatusByWorktree: vi.fn(() => orderHarness.events.push('agent-cleanup')),
clearPaneForegroundAgentByWorktree: vi.fn()
})
shutdownBufferCaptures.set('tab-1', () => orderHarness.events.push('buffer-capture'))
const unsubscribe = store.subscribe((state, previous) => {
if (!previous.pendingPtyShutdownIds['pty-1'] && state.pendingPtyShutdownIds['pty-1']) {
orderHarness.events.push('shutdown-guard-publication')
}
if (previous.ptyIdsByTabId['tab-1']?.length && state.ptyIdsByTabId['tab-1']?.length === 0) {
orderHarness.events.push('terminal-state-cleanup')
}
})
orderHarness.unregister.mockImplementationOnce(() => {
orderHarness.events.push('handler-unregister')
return [
{
ptyId: 'pty-1',
commit: () => orderHarness.events.push('handler-commit'),
rollback: vi.fn()
}
]
})
mockApi.runtimeEnvironments.call.mockImplementation(async (args: { method: string }) => {
const compatibilityResponse = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatibilityResponse) {
return compatibilityResponse
}
if (args.method === 'terminal.sleep') {
orderHarness.events.push('owner-runtime-rpc')
}
return {
id: 'rpc-order',
ok: true,
result:
args.method === 'terminal.sleep'
? {
stopped: 0,
stoppedPtyIds: [],
livePtyIds: [],
postStopVerified: true
}
: {},
_meta: { runtimeId: 'runtime-1' }
}
})
mockApi.pty.kill.mockImplementationOnce(async () => {
orderHarness.events.push('renderer-kill')
})
await store.getState().shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })
expect(orderHarness.events).toEqual([
'buffer-capture',
'shutdown-guard-publication',
'handler-unregister',
'owner-runtime-rpc',
'renderer-kill',
'handler-commit',
'terminal-state-cleanup',
'agent-cleanup',
'deferred-exit-settlement'
])
unsubscribe()
})
})
@@ -244,6 +244,89 @@ describe('terminal tab retirement store boundary', () => {
expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
})
it('retires parked ownership before teardown and sweeps published state before unified close', () => {
const store = createRetirementStore()
const events: string[] = []
const unified = makeUnifiedTab({
id: 'unified-tab-1',
entityId: 'tab-1',
worktreeId: 'wt-1',
groupId: 'group-1'
})
mockKill.mockImplementationOnce(() => {
events.push('provider-kill')
return Promise.resolve()
})
seedStore(store, {
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-1' })]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
unifiedTabsByWorktree: { 'wt-1': [unified] },
dropAgentStatusByTabPrefix: vi.fn(() => {
expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
events.push('state-sweep')
}),
closeUnifiedTab: vi.fn(() => {
events.push('unified-close')
return null
})
})
parkedWatchersByTabId.set('tab-1', {
worktreeId: 'wt-1',
tabPtyId: 'pty-1',
paneIdByPtyId: new Map([['pty-1', 1]]),
disposersByPtyId: new Map([['pty-1', () => events.push('parked-retire')]])
})
const unsubscribe = store.subscribe((state, previous) => {
const wasPresent = previous.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1')
const isPresent = state.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1')
if (wasPresent && !isPresent) {
events.push('row-removal')
}
})
expect(() => store.getState().closeTab('tab-1')).not.toThrow()
expect(events).toEqual([
'parked-retire',
'provider-kill',
'row-removal',
'state-sweep',
'unified-close'
])
unsubscribe()
})
it('starts mixed provider teardown and classifies both rejection kinds after synchronous retirement', async () => {
const store = createRetirementStore()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockKill.mockRejectedValueOnce(new Error('local unavailable'))
mockRuntimeCall.mockRejectedValueOnce(new Error('runtime unavailable'))
seedStore(store, {
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-local' })]
},
ptyIdsByTabId: { 'tab-1': ['pty-local', 'remote:runtime-terminal'] }
})
expect(() => store.getState().closeTab('tab-1')).not.toThrow()
expect(mockKill).toHaveBeenCalledWith('pty-local')
expect(mockRuntimeCall).toHaveBeenCalledWith({
method: 'terminal.close',
params: { terminal: 'runtime-terminal' }
})
expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith('[terminal-retirement] provider teardown failed', {
tabId: 'tab-1',
localOrSshFailures: 1,
runtimeFailures: 1
})
)
warn.mockRestore()
})
it('keeps the tab retired and reports provider rejection without an unhandled promise', async () => {
const store = createRetirementStore()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
import {
clearDirectSshTerminalBindings,
invalidateStaleDirectSshTerminalBindings
} from '../slices/direct-ssh-terminal-recovery'
import {
retryDirectSshTerminalPanes,
retrySettledDirectSshTerminalPane
} from '../slices/direct-ssh-pane-retry-ledger'
import {
directSshAuthoritiesEqual,
settleDirectSshPaneRetryState
} from '../slices/direct-ssh-terminal-authority-ledger'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import {
isCurrentDirectSshAuthority,
resolveDirectSshTerminalKeys
} from './terminal-pty-identities'
export function createDirectSshTerminalBindingActions(
set: TerminalStoreSet,
_get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'clearDirectSshTargetPtyBindings'
| 'invalidateStaleDirectSshTargetPtyBindings'
| 'retryDirectSshTargetPanes'
| 'settleDirectSshPaneRetry'
> {
return {
clearDirectSshTargetPtyBindings: (targetId) => {
let clearedCount = 0
set((s) => {
const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId))
clearedCount = result.clearedCount
return result.patch ?? s
})
return clearedCount
},
invalidateStaleDirectSshTargetPtyBindings: (authority) => {
let clearedCount = 0
set((s) => {
if (!isCurrentDirectSshAuthority(s, authority)) {
return s
}
const result = invalidateStaleDirectSshTerminalBindings(
s,
resolveDirectSshTerminalKeys(s, authority.targetId),
authority
)
clearedCount = result.clearedCount
return result.patch ?? s
})
return clearedCount
},
retryDirectSshTargetPanes: (authority, now = Date.now()) => {
let retriedCount = 0
set((s) => {
if (!isCurrentDirectSshAuthority(s, authority)) {
return s
}
const result = retryDirectSshTerminalPanes(
s,
resolveDirectSshTerminalKeys(s, authority.targetId),
authority,
now
)
retriedCount = result.retriedCount
return result.patch ?? s
})
return retriedCount
},
settleDirectSshPaneRetry: (result, now = Date.now()) => {
set((s) => {
if (!isCurrentDirectSshAuthority(s, result.authority)) {
return s
}
const history = s.directSshPaneRetryHistoryByTabId[result.tabId]
const preservesExhaustedSplitAttempt =
(result.status === 'failed' || result.status === 'timed-out') &&
history != null &&
directSshAuthoritiesEqual(history.authority, result.authority) &&
history.attemptedAt.length >= 2
if (preservesExhaustedSplitAttempt) {
return s
}
const settlement = settleDirectSshPaneRetryState(s, result)
if (!settlement) {
return s
}
const settledState = { ...s, ...settlement }
if (result.status !== 'failed' && result.status !== 'timed-out') {
return settledState
}
const retry = retrySettledDirectSshTerminalPane(
settledState,
resolveDirectSshTerminalKeys(settledState, result.authority.targetId),
result.authority,
result.tabId,
now
)
return retry.patch ? { ...settledState, ...retry.patch } : settledState
})
}
}
}
@@ -0,0 +1,269 @@
import type { Tab } from '../../../../shared/tab-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload'
import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title'
import type {
GeneratedTabTitleUpdate,
TerminalTabTitleUpdate
} from '../slices/terminal-tab-title-batch'
import type {
DirectSshPaneRetryAttemptId,
DirectSshPaneRetryResult
} from '../slices/direct-ssh-terminal-recovery'
import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import type { AgentStatusWorktreeShutdownReason } from '../slices/agent-status'
import type {
TerminalTabCloseReason,
TerminalTabRetirementPlan
} from '../slices/terminal-tab-retirement'
import type {
AutomaticAgentResumeClaim,
CodexRestartNotice,
HydrateWorkspaceSessionOptions,
ReconnectPersistedTerminalsOptions
} from './terminal-contracts'
export type TerminalActions = {
setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void
claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void
seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void
markNativeChatLaunchPromptFailed: (tabId: string) => void
clearNativeChatLaunchPrompt: (tabId: string) => void
seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void
markNativeChatLaunchDraftAdopted: (tabId: string) => void
resolveNativeChatLaunchDraft: (
tabId: string,
resolution: Pick<NativeChatLaunchDraft, 'createdAt' | 'text'>
) => void
clearNativeChatLaunchDraft: (tabId: string) => void
markDefaultTerminalTabsApplied: (worktreeId: string) => void
setHydrationSucceeded: (value: boolean) => void
consumePendingSnapshot: (ptyId: string) => {
snapshot: string
cols?: number
rows?: number
isAlternateScreen?: boolean
} | null
consumePendingColdRestore: (ptyId: string) => {
scrollback: string
cwd: string
} | null
/** Atomically publishes runtime and unified tab ownership, layout, ordering, and focus. */
createTab: (
worktreeId: string,
targetGroupId?: string,
shellOverride?: string,
options?: {
pendingActivationSpawn?: boolean
initialPtyId?: string
activate?: boolean
recordInteraction?: boolean
id?: string
launchAgent?: TuiAgent
quickCommandLabel?: string | null
viewMode?: Tab['viewMode']
startupCwd?: string
forceHostRuntime?: boolean
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
/** Synchronous retirement: provider teardown starts before state removal but is never awaited. */
closeTab: (
tabId: string,
opts?: {
recordInteraction?: boolean
reason?: TerminalTabCloseReason
captureRecentlyClosed?: boolean
remoteCloseOwnedByHost?: boolean
localPtyTeardownOwnedExternally?: boolean
precomputedRetirementPlan?: TerminalTabRetirementPlan
}
) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
setActiveTab: (tabId: string) => void
setActiveTabForWorktree: (worktreeId: string, tabId: string) => void
updateTabTitle: (tabId: string, title: string) => void
updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void
setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void
setGeneratedTabTitleFromAgentPrompt: (
paneKey: string,
prompt: string,
options?: {
replaceExistingGeneratedTitle?: boolean
}
) => void
setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void
clearTabLaunchAgent: (tabId: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
markTerminalTabUnread: (tabId: string) => void
markTerminalPaneUnread: (paneKey: string) => void
markAgentCompletionPaneUnread: (paneKey: string) => void
clearTerminalTabUnread: (tabId: string) => void
clearTerminalPaneUnread: (paneKey: string) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
opts?: {
recordInteraction?: boolean
}
) => void
setTabColor: (tabId: string, color: string | null) => void
/** Binds only live tabs and migrates replacement identity state before publishing ownership. */
updateTabPtyId: (
tabId: string,
ptyId: string,
replacedPtyId?: string,
directSshRetryAttemptId?: DirectSshPaneRetryAttemptId
) => void
/** Reconciles exact exits; bulk clear intentionally retains relay-grace identity. */
clearTabPtyId: (tabId: string, ptyId?: string) => void
clearDirectSshTargetPtyBindings: (targetId: string) => number
invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number
retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number
settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void
/** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */
shutdownWorktreeTerminals: (
worktreeId: string,
opts?: {
keepIdentifiers?: boolean
shutdownReason?: AgentStatusWorktreeShutdownReason
sleepingPaneKeys?: string[]
expectedRuntimePtyIds?: string[]
backendOwnsPtyTeardown?: boolean
}
) => Promise<void>
/** Captures resumable pane evidence and publishes suppression before exact physical teardown. */
shutdownCompletedAgentPaneForHibernation: (
worktreeId: string,
opts: {
paneKey: string
tabId: string
leafId: string
ptyId: string
expectedRuntimePtyId?: string
}
) => Promise<void>
suppressPtyExit: (ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
isPtyShutdownPending: (ptyId: string) => boolean
queueCodexPaneRestarts: (ptyIds: string[]) => void
consumePendingCodexPaneRestart: (ptyId: string) => boolean
markCodexRestartNotices: (
notices: (Pick<CodexRestartNotice, 'previousAccountLabel' | 'nextAccountLabel'> &
Partial<Pick<CodexRestartNotice, 'previousAccountId' | 'nextAccountId'>> & {
homeRouteChanged?: boolean
ptyId: string
})[]
) => string[]
clearCodexRestartNotice: (ptyId: string) => void
dismissCodexRestartNotices: (ptyIds: string[]) => void
reopenCodexRestartPrompt: (ptyId: string) => void
replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
syncPaneDetachPtyOwnership: (args: {
detachedLeafId: string
detachedPtyId: string | null
sourceLayout: TerminalLayoutSnapshot
sourceTabId: string
targetTabId: string
}) => void
queueTabStartupCommand: (
tabId: string,
startup: {
command: string
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
initialAgentStatus?: {
agent: TuiAgent
prompt: string
}
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
consumeTabInitialCwd: (tabId: string) => string | null
consumeTabStartupCommand: (tabId: string) => {
command: string
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
initialAgentStatus?: {
agent: TuiAgent
prompt: string
}
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
} | null
queueTabSetupSplit: (
tabId: string,
startup: {
command: string
env?: Record<string, string>
direction: SetupSplitDirection
}
) => void
consumeTabSetupSplit: (tabId: string) => {
command: string
env?: Record<string, string>
direction: SetupSplitDirection
} | null
queueTabIssueCommandSplit: (
tabId: string,
issueCommand: {
command: string
env?: Record<string, string>
}
) => void
consumeTabIssueCommandSplit: (tabId: string) => {
command: string
env?: Record<string, string>
} | null
setCacheTimerStartedAt: (key: string, ts: number | null) => void
recordTerminalInput: (paneKey: string, timestamp?: number) => void
seedCacheTimersForIdleTabs: () => void
setDeferredSshReconnectTargets: (targetIds: string[]) => void
removeDeferredSshReconnectTarget: (targetId: string) => void
removeDeferredSshSessionId: (tabId: string) => void
/** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */
hydrateWorkspaceSession: (
session: WorkspaceSessionState,
options?: HydrateWorkspaceSessionOptions
) => void
/** Publishes wake hints without spawning; global completion alone opens the readiness gate. */
reconnectPersistedTerminals: (
signal?: AbortSignal,
options?: ReconnectPersistedTerminalsOptions
) => Promise<void>
}
@@ -0,0 +1,69 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
import { isWebClientLocation } from '@/lib/web-client-location'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createActiveWorkspaceTerminalActions(
_set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'openNewTerminalTabInActiveWorkspace'> {
return {
openNewTerminalTabInActiveWorkspace: async (groupId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const workspaceScope = parseWorkspaceKey(worktreeId)
const worktreeRoute =
worktreeId === FLOATING_TERMINAL_WORKTREE_ID || workspaceScope?.type === 'folder'
? null
: resolveWorktreeOperationRouteResult(state, worktreeId)
if (worktreeRoute && worktreeRoute.kind !== 'resolved') {
return
}
const runtimeEnvironmentId = worktreeRoute
? worktreeRoute.route.runtimeEnvironmentId
: getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (runtimeEnvironmentId) {
const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session')
await createWebRuntimeSessionTerminal({
worktreeId,
environmentId: runtimeEnvironmentId,
targetGroupId: groupId,
activate: true
})
return
}
if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
return
}
const terminal = get().createTab(worktreeId, groupId)
get().setActiveTab(terminal.id)
get().setActiveTabType('terminal')
const latest = get()
const currentTerminals = latest.tabsByWorktree[worktreeId] ?? []
const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId)
const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? []
const stored = latest.tabBarOrderByWorktree[worktreeId]
const validIds = new Set([
...currentTerminals.map((tab) => tab.id),
...currentEditors.map((file) => file.id),
...currentBrowsers.map((tab) => tab.id)
])
const base = (stored ?? []).filter((id) => validIds.has(id))
const inBase = new Set(base)
for (const id of validIds) {
if (!inBase.has(id)) {
base.push(id)
}
}
// Why: Cmd+J shares the titlebar-button creation path, so append the new terminal after mixed editor/browser tabs, not first.
get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id])
focusTerminalTabSurface(terminal.id)
}
}
}
@@ -0,0 +1,38 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys'
/** In-memory recovery claim consumed only after the resumed terminal hook becomes live. */
export type AutomaticAgentResumeClaim = {
worktreeId: string
launchAgent: TuiAgent
providerSession: AgentProviderSessionMetadata
}
export type CodexRestartNotice = {
previousAccountLabel: string
nextAccountLabel: string
/** Labels are display-only; account ids disambiguate equal labels and A→B→A collapse. */
previousAccountId?: string | null
nextAccountId?: string | null
/** Persists a home-route mismatch after ephemeral launch-account memory has expired. */
homeRouteChanged?: true
/** Accepted restart remains tracked so a failed execution can reopen instead of input-blocking invisibly. */
restartRequested?: true
/** Dismissal outlives the prompt while explicitly preventing that notice from blocking pane input. */
dismissed?: true
}
/** Scoped direct-SSH hydration replaces only named workspace keys and authority. */
export type HydrateWorkspaceSessionOptions = {
directSshAuthority?: DirectSshAuthority
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
} & WorkspaceSessionHydrationOptions
/** Scoped reconnect must still match this exact provider epoch and connection generation. */
export type ReconnectPersistedTerminalsOptions = {
directSshAuthority: DirectSshAuthority
workspaceKeys: readonly string[]
}
@@ -0,0 +1,237 @@
import { isClaudeAgent } from '@/lib/agent-status'
import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalEphemeralActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'markDefaultTerminalTabsApplied'
| 'setHydrationSucceeded'
| 'setRecentQuickCommandForGroup'
| 'claimAutomaticAgentResume'
| 'seedNativeChatLaunchPrompt'
| 'markNativeChatLaunchPromptFailed'
| 'clearNativeChatLaunchPrompt'
| 'seedNativeChatLaunchDraft'
| 'markNativeChatLaunchDraftAdopted'
| 'resolveNativeChatLaunchDraft'
| 'clearNativeChatLaunchDraft'
| 'recordTerminalInput'
| 'setCacheTimerStartedAt'
| 'seedCacheTimersForIdleTabs'
| 'setDeferredSshReconnectTargets'
| 'removeDeferredSshReconnectTarget'
| 'removeDeferredSshSessionId'
> {
return {
markDefaultTerminalTabsApplied: (worktreeId) =>
set((s) => {
if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
return {}
}
return {
defaultTerminalTabsAppliedByWorktreeId: {
...s.defaultTerminalTabsAppliedByWorktreeId,
[worktreeId]: true
}
}
}),
setHydrationSucceeded: (value) => {
set({ hydrationSucceeded: value })
},
setRecentQuickCommandForGroup: (groupId, quickCommandId) => {
set((s) => ({
recentQuickCommandIdByGroup: {
...s.recentQuickCommandIdByGroup,
[groupId]: quickCommandId
}
}))
},
claimAutomaticAgentResume: (tabId, claim) => {
set((s) => ({
automaticAgentResumeClaimsByTabId: {
...s.automaticAgentResumeClaimsByTabId,
[tabId]: claim
}
}))
},
seedNativeChatLaunchPrompt: (prompt) => {
set((s) => ({
nativeChatLaunchPromptByTabId: {
...s.nativeChatLaunchPromptByTabId,
[prompt.tabId]: prompt
}
}))
},
markNativeChatLaunchPromptFailed: (tabId) => {
set((s) => {
const current = s.nativeChatLaunchPromptByTabId[tabId]
if (!current || current.failed) {
return {}
}
return {
nativeChatLaunchPromptByTabId: {
...s.nativeChatLaunchPromptByTabId,
[tabId]: { ...current, failed: true }
}
}
})
},
clearNativeChatLaunchPrompt: (tabId) => {
set((s) => {
if (!s.nativeChatLaunchPromptByTabId[tabId]) {
return {}
}
const next = { ...s.nativeChatLaunchPromptByTabId }
delete next[tabId]
return { nativeChatLaunchPromptByTabId: next }
})
},
seedNativeChatLaunchDraft: (draft) => {
set((s) => ({
nativeChatLaunchDraftByTabId: {
...s.nativeChatLaunchDraftByTabId,
[draft.tabId]: draft
}
}))
},
markNativeChatLaunchDraftAdopted: (tabId) => {
set((s) => {
const current = s.nativeChatLaunchDraftByTabId[tabId]
if (!current || current.adopted) {
return {}
}
return {
nativeChatLaunchDraftByTabId: {
...s.nativeChatLaunchDraftByTabId,
[tabId]: { ...current, adopted: true }
}
}
})
},
resolveNativeChatLaunchDraft: (tabId, resolution) => {
set((s) => {
const current = s.nativeChatLaunchDraftByTabId[tabId]
if (
!current ||
current.resolved ||
current.createdAt !== resolution.createdAt ||
current.text !== resolution.text
) {
return {}
}
return {
nativeChatLaunchDraftByTabId: {
...s.nativeChatLaunchDraftByTabId,
[tabId]: { ...current, resolved: true }
}
}
})
},
clearNativeChatLaunchDraft: (tabId) => {
set((s) => {
if (!s.nativeChatLaunchDraftByTabId[tabId]) {
return {}
}
const next = { ...s.nativeChatLaunchDraftByTabId }
delete next[tabId]
return { nativeChatLaunchDraftByTabId: next }
})
},
recordTerminalInput: (paneKey, timestamp = Date.now()) => {
if (!paneKey || !Number.isFinite(timestamp)) {
return
}
recordTerminalInputActivity({
paneKey,
timestamp,
// Why: the first stamp for a pane must land synchronously; automation take-over
// detection subscribes and compares undefined→value across a launch.
forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined,
commit: {
insert: (key, at) =>
set((s) => ({
lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at }
})),
refreshExisting: (entries) =>
set((s) => {
let next: Record<string, number> | null = null
for (const [key, at] of entries) {
// Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them.
const current = s.lastTerminalInputAtByPaneKey[key]
if (current === undefined || current >= at) {
continue
}
next ??= { ...s.lastTerminalInputAtByPaneKey }
next[key] = at
}
return next ? { lastTerminalInputAtByPaneKey: next } : {}
})
}
})
},
setCacheTimerStartedAt: (key, ts) => {
set((s) => {
// Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane.
const colonIdx = key.indexOf(':')
const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1)
const seedKey =
colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null
const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey
// Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector.
if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) {
return s
}
const next = { ...s.cacheTimerByKey, [key]: ts }
if (seedKey !== null) {
delete next[seedKey]
}
return { cacheTimerByKey: next }
})
},
seedCacheTimersForIdleTabs: () => {
// Why: tabs already idle when the feature is enabled mid-session missed their working→idle transition, so seed timers for them.
const s = get()
const now = Date.now()
const updates: Record<string, number> = {}
for (const tabs of Object.values(s.tabsByWorktree)) {
for (const tab of tabs) {
if (!tab.title || !isClaudeAgent(tab.title)) {
continue
}
const status = classifyTitleActivity(tab.title)
if (status === null || status === 'working') {
continue
}
// Why: the store doesn't know which pane holds the idle session, so use a ':seed' sentinel; setCacheTimerStartedAt clears it on any real pane write.
const key = `${tab.id}:seed`
if (s.cacheTimerByKey[key] == null) {
updates[key] = now
}
}
}
if (Object.keys(updates).length > 0) {
set((s) => ({
cacheTimerByKey: { ...s.cacheTimerByKey, ...updates }
}))
}
},
setDeferredSshReconnectTargets: (targetIds) => set({ deferredSshReconnectTargets: targetIds }),
removeDeferredSshReconnectTarget: (targetId) =>
set((s) => ({
deferredSshReconnectTargets: s.deferredSshReconnectTargets.filter((id) => id !== targetId)
})),
removeDeferredSshSessionId: (tabId) =>
set((s) => {
if (!s.deferredSshSessionIdsByTabId[tabId]) {
return {}
}
const next = { ...s.deferredSshSessionIdsByTabId }
delete next[tabId]
return { deferredSshSessionIdsByTabId: next }
})
}
}
@@ -0,0 +1,154 @@
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import {
normalizeTerminalLayoutPtyOwnership,
resolveTerminalLayoutPtyOwnershipTransfers
} from '@/components/terminal-pane/terminal-layout-pty-ownership'
import { transferDirectSshPaneDetachLedger } from '../slices/direct-ssh-terminal-authority-ledger'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import {
isCurrentDirectSshAuthority,
resolvePrimaryLayoutPtyId,
uniquePtyIds,
withTerminalTabPtyId
} from './terminal-pty-identities'
import { transferNormalizedTerminalLayoutPtyOwnership } from './workspace-terminal-hydration-patch'
export function createTerminalLayoutActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'replaceTerminalLayoutPanePtyId'
| 'setTabPaneExpanded'
| 'setTabCanExpandPane'
| 'setTabLayout'
| 'syncPaneDetachPtyOwnership'
> {
return {
replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => {
set((s) => {
const layout = s.terminalLayoutsByTabId[tabId]
if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) {
return {}
}
return {
terminalLayoutsByTabId: {
...s.terminalLayoutsByTabId,
[tabId]: {
...layout,
ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId }
}
}
}
})
},
setTabPaneExpanded: (tabId, expanded) => {
set((s) => ({
expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded }
}))
},
setTabCanExpandPane: (tabId, canExpand) => {
set((s) => ({
canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand }
}))
},
setTabLayout: (tabId, layout) => {
let ownershipTransfers: ReturnType<typeof resolveTerminalLayoutPtyOwnershipTransfers> = []
set((s) => {
if (!layout) {
if (!(tabId in s.terminalLayoutsByTabId)) {
return s
}
const next = { ...s.terminalLayoutsByTabId }
delete next[tabId]
return { terminalLayoutsByTabId: next }
}
const normalized = normalizeTerminalLayoutPtyOwnership(layout)
// Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched.
if (normalized.changed) {
ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(
layout,
normalized.snapshot
)
}
// Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep.
const existing = s.terminalLayoutsByTabId[tabId]
if (existing && terminalLayoutEqual(existing, normalized.snapshot)) {
return s
}
return {
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot }
}
})
transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers)
},
syncPaneDetachPtyOwnership: ({
detachedLeafId,
detachedPtyId,
sourceLayout,
sourceTabId,
targetTabId
}) => {
const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId)
const targetPaneKey = makePaneKey(targetTabId, detachedLeafId)
set((s) => {
const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {}))
const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter(
(ptyId) => ptyId !== detachedPtyId
)
const sourcePtyIds =
layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds
const sourcePrimaryPtyId =
resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null
const nextPtyIdsByTabId = {
...s.ptyIdsByTabId,
[sourceTabId]: sourcePtyIds
}
if (detachedPtyId) {
nextPtyIdsByTabId[targetTabId] = uniquePtyIds([
...(nextPtyIdsByTabId[targetTabId] ?? []),
detachedPtyId
])
}
const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId }
if (sourcePrimaryPtyId) {
nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId
} else {
delete nextLastKnownRelayPtyIdByTabId[sourceTabId]
}
if (detachedPtyId) {
nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId
}
// Why: pane-to-tab detach moves a live PTY without spawning or exiting, so transfer identity without activity bumps.
const sourceTabsByWorktree = withTerminalTabPtyId(
s.tabsByWorktree,
sourceTabId,
sourcePrimaryPtyId
)
const nextTabsByWorktree = detachedPtyId
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
: sourceTabsByWorktree
const directSshLedger = transferDirectSshPaneDetachLedger(s, {
detachedPtyId,
sourcePtyId: sourcePrimaryPtyId,
sourceTabId,
targetTabId,
isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority)
})
return {
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
...directSshLedger,
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
}
})
// Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes.
get().transferAgentPaneAuthority({
fromPaneKey: sourcePaneKey,
toPaneKey: targetPaneKey,
ptyId: detachedPtyId
})
}
}
}
@@ -0,0 +1,243 @@
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import {
restorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers
} from '@/components/terminal-pane/pty-transport'
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree
} from '../slices/agent-status'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities'
import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing'
export function createTerminalPaneHibernationActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'shutdownCompletedAgentPaneForHibernation'> {
return {
shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => {
const paneKeys = [opts.paneKey]
const expectedRuntimePtyIds = sortedUniquePtyIds(
opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : []
)
const rendererShutdownPtyIds = [opts.ptyId]
const state = get()
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId)
// Why: pane transports emit renderer PTY ids, not raw exact-stop handles; guard only the identity that can deliver an exit callback.
const exitGuardPtyIds = [opts.ptyId]
const tab = (state.tabsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.id === opts.tabId
)
const parsed = parsePaneKey(opts.paneKey)
const layout = state.terminalLayoutsByTabId[opts.tabId]
const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? []
if (
!tab ||
!parsed ||
parsed.tabId !== opts.tabId ||
parsed.leafId !== opts.leafId ||
layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId ||
(expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId))
) {
throw new Error('agent_hibernation_pane_binding_mismatch')
}
const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree(
state,
worktreeId,
{
paneKeys,
captureMode: 'completed-agent-hibernation'
}
)
const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree(
state,
worktreeId,
paneKeys
)
if (!sleepingAgentSessionRecords[opts.paneKey]) {
// Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably.
throw new Error('agent_hibernation_capture_missing')
}
const capture = shutdownBufferCaptures.get(opts.tabId)
if (capture) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one tab's capture failure block the pane hibernation.
}
}
// Why: store sleeping records before kill, since pty:exit can arrive first.
const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords)
const replacedSleepingRecords: Record<string, (typeof sleepingAgentSessionRecords)[string]> =
{}
for (const key of sleepingRecordKeys) {
const existing = state.sleepingAgentSessionsByPaneKey[key]
if (existing) {
replacedSleepingRecords[key] = existing
}
}
const rollbackTargetShutdownState = (): void => {
set((s) => {
const next = { ...s.suppressedPtyExitIds }
for (const ptyId of exitGuardPtyIds) {
delete next[ptyId]
}
const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey }
for (const key of sleepingRecordKeys) {
const replaced = replacedSleepingRecords[key]
if (replaced) {
nextSleeping[key] = replaced
} else {
delete nextSleeping[key]
}
}
return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping }
})
}
set((s) => ({
suppressedPtyExitIds: {
...s.suppressedPtyExitIds,
...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const))
},
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
...sleepingAgentSessionRecords
}
}))
if (expectedRuntimePtyIds.length > 0) {
if (!runtimeEnvironmentId) {
rollbackTargetShutdownState()
throw new Error('missing_runtime_for_exact_terminal_stop')
}
let stopResult: {
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
}
try {
stopResult = await callRuntimeRpc<{
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
}>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stopExact',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
expectedPtyIds: expectedRuntimePtyIds,
keepHistory: true,
targetOnly: true
},
{ timeoutMs: 15000 }
)
} catch (err) {
rollbackTargetShutdownState()
throw err
}
const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds)
const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds)
const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId))
if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) {
rollbackTargetShutdownState()
throw new Error('exact_terminal_stop_mismatch')
}
if (stopResult.postStopVerified !== true) {
rollbackTargetShutdownState()
throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified')
}
for (const snapshot of unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []) {
snapshot.commit?.()
}
} else if (!opts.ptyId.startsWith('remote:')) {
// Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation.
const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []
try {
await window.api.pty.kill(opts.ptyId, { keepHistory: true })
} catch (err) {
restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots)
rollbackTargetShutdownState()
throw err
}
for (const snapshot of handlerSnapshots) {
snapshot.commit?.()
}
}
set((s) => {
const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? []
const shutdownPtyIdSet = new Set(rendererShutdownPtyIds)
const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId))
const nextTabsByWorktree = { ...s.tabsByWorktree }
const tabs = nextTabsByWorktree[worktreeId] ?? []
const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId)
if (tabIndex !== -1) {
const nextTabs = [...tabs]
nextTabs[tabIndex] = {
...nextTabs[tabIndex],
ptyId: remainingPtyIds.at(-1) ?? null
}
nextTabsByWorktree[worktreeId] = nextTabs
}
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
for (const ptyId of exitGuardPtyIds) {
delete nextCodexRestartNoticeByPtyId[ptyId]
}
const nextLastKnownRelay =
remainingPtyIds.length === 0
? { ...s.lastKnownRelayPtyIdByTabId }
: s.lastKnownRelayPtyIdByTabId
if (remainingPtyIds.length === 0) {
delete nextLastKnownRelay[opts.tabId]
}
let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId
const numericPaneId = Number(opts.leafId)
if (
Number.isInteger(numericPaneId) &&
s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId]
) {
const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] }
delete nextByPane[numericPaneId]
nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
if (Object.keys(nextByPane).length > 0) {
nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane
} else {
delete nextRuntimePaneTitlesByTabId[opts.tabId]
}
}
const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes }
const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes }
const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey }
delete nextUnreadTerminalPanes[opts.paneKey]
delete nextUnreadAgentCompletionPanes[opts.paneKey]
delete nextLastTerminalInputAtByPaneKey[opts.paneKey]
return {
tabsByWorktree: nextTabsByWorktree,
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[opts.tabId]: remainingPtyIds
},
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
suppressedPtyExitIds: {
...s.suppressedPtyExitIds,
...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const))
},
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId
? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId }
: {}),
unreadTerminalPanes: nextUnreadTerminalPanes,
unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes,
lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey
}
})
get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, {
retainedCompletionEvidence
})
}
}
}
@@ -0,0 +1,240 @@
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { isTerminalTabPresent } from '../slices/terminal-tab-retirement'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import {
consumePendingActivationSpawn,
getPendingActivationSpawnCount,
isCurrentDirectSshAuthority,
isRemoteRuntimePtyId
} from './terminal-pty-identities'
export function createTerminalPtyBindingActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'updateTabPtyId'> {
return {
updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => {
// Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown).
if (!isTerminalTabPresent(get(), tabId)) {
return
}
let worktreeId: string | null = null
let wasActivationSpawn = false
const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId)
set((s) => {
if (directSshRetryAttemptId) {
const pendingRetry = s.directSshPaneRetryByTabId[tabId]
const liveRetry = s.directSshLivePtyBindingByTabId[tabId]
const retryLease =
pendingRetry?.attemptId === directSshRetryAttemptId
? pendingRetry
: liveRetry?.attemptId === directSshRetryAttemptId
? liveRetry
: undefined
const boundTab = Object.values(s.tabsByWorktree)
.flat()
.find((candidate) => candidate.id === tabId)
if (
!retryLease ||
!boundTab ||
parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId ||
!isCurrentDirectSshAuthority(s, retryLease.authority) ||
(boundTab.generation ?? 0) !== retryLease.tabGeneration
) {
return s
}
}
const existingPtyIds = s.ptyIdsByTabId[tabId] ?? []
const remote = parseRemoteRuntimePtyId(ptyId)
const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null
const hasLegacyPtyBinding = legacyRemotePtyId
? existingPtyIds.includes(legacyRemotePtyId)
: false
const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined
const replacementPtyId =
explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null)
const boundReplacementPtyId =
replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null
const nextPtyIds = boundReplacementPtyId
? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))]
: existingPtyIds.includes(ptyId)
? existingPtyIds
: [...existingPtyIds, ptyId]
let nextTabsByWorktree = s.tabsByWorktree
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
const index = tabs.findIndex((t) => t.id === tabId)
if (index === -1) {
continue
}
worktreeId = wId
const tab = tabs[index]
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
wasActivationSpawn = true
}
// Why: consume one suppression per split-pane activation callback.
const { pendingActivationSpawn: _unused, ...rest } = tab
void _unused
// Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY.
const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId
const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null
const nextPendingActivationSpawn = consumePendingActivationSpawn(
tab.pendingActivationSpawn
)
if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) {
const nextTabs = [...tabs]
nextTabs[index] = {
...rest,
...(nextPendingActivationSpawn
? { pendingActivationSpawn: nextPendingActivationSpawn }
: {}),
ptyId: nextTabPtyId
}
nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs }
}
break
}
// Why: the first active PTY changes sorting, except activation-spawn side effects.
const isFirstPty = existingPtyIds.length === 0
const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId
const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn
const shouldRetainSuppressedExit = Boolean(
explicitReplacementPtyId &&
(s.suppressedPtyExitIds[ptyId] ||
(replacementPtyId && s.suppressedPtyExitIds[replacementPtyId]))
)
const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds }
delete nextSuppressedPtyExitIds[ptyId]
if (replacementPtyId) {
delete nextSuppressedPtyExitIds[replacementPtyId]
}
if (shouldRetainSuppressedExit) {
// Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once.
nextSuppressedPtyExitIds[ptyId] = true
}
const hasReplacementPendingRestart = replacementPtyId
? replacementPtyId in s.pendingCodexPaneRestartIds
: false
const hasReplacementRestartNotice = replacementPtyId
? replacementPtyId in s.codexRestartNoticeByPtyId
: false
const hasReplacementMigrationUnsupported = replacementPtyId
? replacementPtyId in s.migrationUnsupportedByPtyId
: false
const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart
? { ...s.pendingCodexPaneRestartIds }
: s.pendingCodexPaneRestartIds
const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice
? { ...s.codexRestartNoticeByPtyId }
: s.codexRestartNoticeByPtyId
const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported
? { ...s.migrationUnsupportedByPtyId }
: s.migrationUnsupportedByPtyId
if (replacementPtyId) {
if (hasReplacementPendingRestart) {
nextPendingCodexPaneRestartIds[ptyId] = true
delete nextPendingCodexPaneRestartIds[replacementPtyId]
}
if (hasReplacementRestartNotice) {
const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId]
nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice
delete nextCodexRestartNoticeByPtyId[replacementPtyId]
}
if (hasReplacementMigrationUnsupported) {
const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId]
nextMigrationUnsupportedByPtyId[ptyId] ??= {
...replacedMigrationUnsupported,
ptyId
}
delete nextMigrationUnsupportedByPtyId[replacementPtyId]
}
}
const pendingRetry = s.directSshPaneRetryByTabId[tabId]
const liveRetry = s.directSshLivePtyBindingByTabId[tabId]
const retryLease =
pendingRetry?.attemptId === directSshRetryAttemptId
? pendingRetry
: liveRetry?.attemptId === directSshRetryAttemptId
? liveRetry
: undefined
const boundTab = worktreeId
? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId)
: undefined
const parsedSshPty = parseAppSshPtyId(ptyId)
const acknowledgesDirectSshRetry = Boolean(
retryLease &&
boundTab &&
parsedSshPty?.connectionId === retryLease.authority.targetId &&
isCurrentDirectSshAuthority(s, retryLease.authority) &&
(boundTab.generation ?? 0) === retryLease.tabGeneration &&
nextPtyIds.includes(ptyId)
)
let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId
let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId
if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) {
if (pendingRetry?.attemptId === directSshRetryAttemptId) {
nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId }
delete nextDirectSshPaneRetryByTabId[tabId]
}
if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) {
nextDirectSshLivePtyBindingByTabId = {
...s.directSshLivePtyBindingByTabId,
[tabId]: {
attemptId: retryLease.attemptId,
authority: retryLease.authority,
tabGeneration: retryLease.tabGeneration,
ptyId: boundTab.ptyId
}
}
} else if (
(replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) ||
!nextPtyIds.includes(liveRetry.ptyId)
) {
nextDirectSshLivePtyBindingByTabId = {
...s.directSshLivePtyBindingByTabId,
[tabId]: { ...liveRetry, ptyId: boundTab.ptyId }
}
}
} else {
const liveBinding = s.directSshLivePtyBindingByTabId[tabId]
if (liveBinding) {
if (
replacementPtyId === liveBinding.ptyId &&
boundTab?.ptyId === ptyId &&
isCurrentDirectSshAuthority(s, liveBinding.authority)
) {
nextDirectSshLivePtyBindingByTabId = {
...s.directSshLivePtyBindingByTabId,
[tabId]: { ...liveBinding, ptyId }
}
}
}
}
return {
...(nextTabsByWorktree !== s.tabsByWorktree
? { tabsByWorktree: nextTabsByWorktree }
: {}),
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[tabId]: nextPtyIds
},
lastKnownRelayPtyIdByTabId: {
...s.lastKnownRelayPtyIdByTabId,
[tabId]: ptyId
},
suppressedPtyExitIds: nextSuppressedPtyExitIds,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds,
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId,
directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId,
directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId,
...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {})
}
})
// Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump.
if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) {
get().bumpWorktreeActivity(worktreeId)
}
}
}
}
@@ -0,0 +1,122 @@
import type { AppState } from '../types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope'
let terminalTabOwnerCacheSource: Record<string, TerminalTab[]> | null = null
let terminalTabOwnerCache = new Map<string, string>()
export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null
}
export function isCurrentDirectSshAuthority(
state: AppState,
authority: DirectSshAuthority
): boolean {
const current = state.sshConnectionStates.get(authority.targetId)
return Boolean(
current?.status === 'connected' &&
current.providerEpoch === authority.providerEpoch &&
current.connectionGeneration === authority.connectionGeneration
)
}
export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set<string> {
return resolveDirectSshTerminalWorkspaceKeys(
{
targetId,
catalogRevision: 0,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey
},
state.tabsByWorktree,
state.lastKnownRelayPtyIdByTabId
)
}
export function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
if (value === true) {
return 1
}
return typeof value === 'number' && value > 0 ? value : 0
}
export function consumePendingActivationSpawn(
value: boolean | number | undefined
): boolean | number | undefined {
const count = getPendingActivationSpawnCount(value)
if (count <= 1) {
return undefined
}
return count === 2 ? true : count - 1
}
export function getTerminalTabOwnerWorktreeId(
tabsByWorktree: Record<string, TerminalTab[]>,
tabId: string
): string | null {
if (terminalTabOwnerCacheSource !== tabsByWorktree) {
const nextCache = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
for (const tab of tabs) {
nextCache.set(tab.id, worktreeId)
}
}
terminalTabOwnerCacheSource = tabsByWorktree
terminalTabOwnerCache = nextCache
}
return terminalTabOwnerCache.get(tabId) ?? null
}
export function getTabIdFromPaneKey(paneKey: string): string | null {
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
}
export function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] {
return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort()
}
export function equalStringSets(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
}
const bSet = new Set(b)
return a.every((value) => bSet.has(value))
}
export function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] {
return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))]
}
export function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null {
const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {}
const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined
return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null
}
export function withTerminalTabPtyId(
tabsByWorktree: Record<string, TerminalTab[]>,
tabId: string,
ptyId: string | null
): Record<string, TerminalTab[]> {
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index === -1) {
continue
}
if (tabs[index]?.ptyId === ptyId) {
return tabsByWorktree
}
const nextTabs = [...tabs]
nextTabs[index] = { ...nextTabs[index]!, ptyId }
return { ...tabsByWorktree, [worktreeId]: nextTabs }
}
return tabsByWorktree
}
@@ -0,0 +1,161 @@
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import {
consumePendingActivationSpawn,
getPendingActivationSpawnCount,
isCurrentDirectSshAuthority,
isRemoteRuntimePtyId
} from './terminal-pty-identities'
export function createTerminalPtyReleaseActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'clearTabPtyId'> {
return {
clearTabPtyId: (tabId, ptyId) => {
if (ptyId && get().pendingPtyShutdownIds[ptyId]) {
// Why: an owner exit can arrive before its post-stop inventory; keep the renderer binding retryable until verification commits.
return
}
let worktreeId: string | null = null
let wasActivationSpawn = false
let preservesDirectSshContinuationGap = false
let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId)
set((s) => {
const existingPtyIds = s.ptyIdsByTabId[tabId] ?? []
const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : []
const liveBinding = s.directSshLivePtyBindingByTabId[tabId]
let nextTabsByWorktree = s.tabsByWorktree
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
const index = tabs.findIndex((t) => t.id === tabId)
if (index === -1) {
continue
}
worktreeId = wId
const tab = tabs[index]
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
wasActivationSpawn = true
}
if (!ptyId) {
isRemoteRuntimeMirror =
existingPtyIds.length > 0 && existingPtyIds.every((id) => isRemoteRuntimePtyId(id))
}
// Why: consume pendingActivationSpawn on real activation clears, but keep it when clearing a stale wake-hint id — its fallback spawn still needs the suppression.
const { pendingActivationSpawn: _unused, ...rest } = tab
void _unused
const nextTabPtyId = remainingPtyIds.at(-1) ?? null
preservesDirectSshContinuationGap = Boolean(
ptyId &&
remainingPtyIds.length === 0 &&
liveBinding?.ptyId === ptyId &&
getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 &&
isCurrentDirectSshAuthority(s, liveBinding.authority)
)
const shouldRetainActivationSpawn =
preservesDirectSshContinuationGap ||
(wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId))
const nextPendingActivationSpawn = shouldRetainActivationSpawn
? tab.pendingActivationSpawn
: consumePendingActivationSpawn(tab.pendingActivationSpawn)
if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) {
const nextTabs = [...tabs]
nextTabs[index] = {
...rest,
...(nextPendingActivationSpawn
? { pendingActivationSpawn: nextPendingActivationSpawn }
: {}),
ptyId: nextTabPtyId
}
nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs }
}
break
}
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
if (worktreeId) {
nextPtyIdsByTabId[tabId] = remainingPtyIds
} else {
// Why: repo purge can retire the owning tab before its async exit arrives; don't resurrect an orphan PTY index.
delete nextPtyIdsByTabId[tabId]
}
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
if (ptyId) {
delete nextPendingCodexPaneRestartIds[ptyId]
delete nextCodexRestartNoticeByPtyId[ptyId]
} else {
for (const currentPtyId of s.ptyIdsByTabId[tabId] ?? []) {
delete nextPendingCodexPaneRestartIds[currentPtyId]
delete nextCodexRestartNoticeByPtyId[currentPtyId]
}
}
// Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace.
const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId }
if (ptyId && nextLastKnownRelay[tabId] === ptyId) {
// Why: the relay slot holds ONE id per tab (the last pane to bind). If
// that pane exits, promote a surviving pane instead of clearing — else the
// survivor is left visible only in the layout leaf map, and a later
// relay-drop bulk-clear lets the orphan sweep delete the still-live tab
// (the orphan predicate reads this map but not layout leaves) (#9911).
const survivingPtyId = remainingPtyIds.at(-1)
if (survivingPtyId) {
nextLastKnownRelay[tabId] = survivingPtyId
} else {
delete nextLastKnownRelay[tabId]
}
}
let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId
const pendingRetry = s.directSshPaneRetryByTabId[tabId]
if (
pendingRetry &&
(!ptyId ||
(existingPtyIds.includes(ptyId) &&
parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId))
) {
nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId }
delete nextDirectSshPaneRetryByTabId[tabId]
}
let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId
if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) {
nextDirectSshLivePtyBindingByTabId = {
...s.directSshLivePtyBindingByTabId
}
const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined
if (
promotedPtyId &&
parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId &&
isCurrentDirectSshAuthority(s, liveBinding.authority)
) {
nextDirectSshLivePtyBindingByTabId[tabId] = {
...liveBinding,
ptyId: promotedPtyId
}
} else if (!preservesDirectSshContinuationGap) {
delete nextDirectSshLivePtyBindingByTabId[tabId]
}
}
return {
...(nextTabsByWorktree !== s.tabsByWorktree
? { tabsByWorktree: nextTabsByWorktree }
: {}),
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds,
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId,
directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId
}
})
// Bump activity on PTY exit, but skip intentional shutdowns (suppressed exits) and click-driven pane unmounts (pendingActivationSpawn).
if (
worktreeId &&
!wasActivationSpawn &&
!isRemoteRuntimeMirror &&
!hasWorktreeSleepIntent(worktreeId) &&
!(ptyId && get().suppressedPtyExitIds[ptyId])
) {
get().bumpWorktreeActivity(worktreeId)
}
}
}
}
@@ -0,0 +1,202 @@
import { isSameCodexRestartNoticeAccount } from '../slices/codex-restart-notice-account-identity'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalRestartActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'consumeSuppressedPtyExit'
| 'isPtyShutdownPending'
| 'suppressPtyExit'
| 'queueCodexPaneRestarts'
| 'consumePendingCodexPaneRestart'
| 'markCodexRestartNotices'
| 'clearCodexRestartNotice'
| 'dismissCodexRestartNotices'
| 'reopenCodexRestartPrompt'
> {
return {
consumeSuppressedPtyExit: (ptyId) => {
let wasSuppressed = false
set((s) => {
if (!s.suppressedPtyExitIds[ptyId]) {
return {}
}
wasSuppressed = true
const next = { ...s.suppressedPtyExitIds }
delete next[ptyId]
return { suppressedPtyExitIds: next }
})
return wasSuppressed
},
isPtyShutdownPending: (ptyId) => (get().pendingPtyShutdownIds[ptyId] ?? 0) > 0,
suppressPtyExit: (ptyId) => {
set((s) => ({
suppressedPtyExitIds: { ...s.suppressedPtyExitIds, [ptyId]: true }
}))
},
queueCodexPaneRestarts: (ptyIds) => {
if (ptyIds.length === 0) {
return
}
set((s) => {
// Why: the prompt is answered the moment the user asks for a restart. A
// pane whose tab isn't mounted can only restart when it next mounts, and
// leaving the prompt up re-showed a button that now does nothing.
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
for (const ptyId of ptyIds) {
const notice = nextCodexRestartNoticeByPtyId[ptyId]
if (notice) {
// Why: mirror of the strip in dismissCodexRestartNotices. A queued
// restart leaves the pane on the old account until it relaunches, so
// it must re-block input that an earlier dismissal had freed.
const { dismissed: _dismissed, ...kept } = notice
nextCodexRestartNoticeByPtyId[ptyId] = { ...kept, restartRequested: true }
}
}
return {
pendingCodexPaneRestartIds: {
...s.pendingCodexPaneRestartIds,
...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const))
},
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId
}
})
},
consumePendingCodexPaneRestart: (ptyId) => {
let wasQueued = false
set((s) => {
if (!s.pendingCodexPaneRestartIds[ptyId]) {
return {}
}
wasQueued = true
const next = { ...s.pendingCodexPaneRestartIds }
delete next[ptyId]
return { pendingCodexPaneRestartIds: next }
})
return wasQueued
},
markCodexRestartNotices: (notices) => {
if (notices.length === 0) {
return []
}
const noticedPtyIds: string[] = []
set((s) => {
const next = { ...s.codexRestartNoticeByPtyId }
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
for (const notice of notices) {
const existing = next[notice.ptyId]
// Why one record rather than two lookups: the label and the id of the
// launch account have to come from the same source, or they can end up
// describing two different accounts.
const launch = existing ?? notice
const target = { id: notice.nextAccountId, label: notice.nextAccountLabel }
const homeRouteChanged =
notice.homeRouteChanged === undefined
? existing?.homeRouteChanged === true
: notice.homeRouteChanged
// Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice.
if (
!homeRouteChanged &&
isSameCodexRestartNoticeAccount(
{ id: launch.previousAccountId, label: launch.previousAccountLabel },
target
)
) {
delete next[notice.ptyId]
delete nextPendingCodexPaneRestartIds[notice.ptyId]
continue
}
next[notice.ptyId] = {
previousAccountLabel: launch.previousAccountLabel,
nextAccountLabel: notice.nextAccountLabel,
...(launch.previousAccountId === undefined
? {}
: { previousAccountId: launch.previousAccountId }),
...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }),
...(homeRouteChanged ? { homeRouteChanged: true as const } : {}),
// Why: a queued restart relaunches under whatever account is selected
// when it runs, so a later switch does not reopen an answered prompt.
...(existing?.restartRequested ? { restartRequested: true as const } : {}),
// Why: a dismissal answered one question — "keep the launch account
// instead of this one". Only a genuinely different target re-asks it,
// so a later C reopens the prompt while adding an account or
// reauthenticating the active one (both re-mark live panes with the
// selection unchanged) must not resurrect it and re-mute the pane.
...(existing?.dismissed &&
isSameCodexRestartNoticeAccount(
{ id: existing.nextAccountId, label: existing.nextAccountLabel },
target
)
? { dismissed: true as const }
: {})
}
noticedPtyIds.push(notice.ptyId)
}
return {
codexRestartNoticeByPtyId: next,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds
}
})
return noticedPtyIds
},
clearCodexRestartNotice: (ptyId) => {
set((s) => {
if (!s.codexRestartNoticeByPtyId[ptyId]) {
return {}
}
const next = { ...s.codexRestartNoticeByPtyId }
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
delete next[ptyId]
delete nextPendingCodexPaneRestartIds[ptyId]
return {
codexRestartNoticeByPtyId: next,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds
}
})
},
dismissCodexRestartNotices: (ptyIds) => {
set((s) => {
// Why: keeping the old account is an answer, not a restart — the record
// stays so `previousAccountLabel` still names the pane's launch account,
// but every consumer treats it as answered (prompt hidden, input freed).
const next = { ...s.codexRestartNoticeByPtyId }
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
let changed = false
for (const ptyId of ptyIds) {
const notice = next[ptyId]
if (!notice || notice.dismissed) {
continue
}
const { restartRequested: _restartRequested, ...kept } = notice
next[ptyId] = { ...kept, dismissed: true }
delete nextPendingCodexPaneRestartIds[ptyId]
changed = true
}
if (!changed) {
return {}
}
return {
codexRestartNoticeByPtyId: next,
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds
}
})
},
reopenCodexRestartPrompt: (ptyId) => {
set((s) => {
const notice = s.codexRestartNoticeByPtyId[ptyId]
if (!notice?.restartRequested) {
return {}
}
const { restartRequested: _restartRequested, ...kept } = notice
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
delete nextPendingCodexPaneRestartIds[ptyId]
return {
codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept },
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds
}
})
}
}
}
@@ -0,0 +1,185 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
restorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers
} from '@/components/terminal-pane/pty-transport'
import type { PtyDataHandlerShutdownSnapshot } from '@/components/terminal-pane/pty-shutdown-data-suspension'
import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry'
import {
clearCommittedPtyShutdownSettlements,
hasCommittedPtyShutdownSettlement,
markCommittedPtyShutdowns,
noteCommittedPtyShutdownSettlements,
settleDeferredPtyShutdownExits
} from '@/components/terminal-pane/pty-shutdown-exit-deferral'
import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export type TerminalShutdownGuardController = {
commitHandlerSnapshots: () => void
markShutdownPending: () => void
rollbackShutdown: () => void
settlePartialRendererStop: (stoppedPtyIds: readonly string[]) => void
stopRendererPtys: () => Promise<{
stoppedPtyIds: string[]
failure?: PromiseRejectedResult
}>
unregisterHandlers: () => void
wasPartialRendererStopSettled: () => boolean
}
export function createTerminalShutdownGuardController({
exitGuardPtyIds,
get,
keepIdentifiers,
rendererShutdownPtyIds,
runtimeEnvironmentId,
set,
tabs
}: {
exitGuardPtyIds: readonly string[]
get: TerminalStoreGet
keepIdentifiers: boolean
rendererShutdownPtyIds: readonly string[]
runtimeEnvironmentId: string | null
set: TerminalStoreSet
tabs: readonly TerminalTab[]
}): TerminalShutdownGuardController {
let handlerSnapshots: PtyDataHandlerShutdownSnapshot[] = []
let partialRendererStopSettled = false
const markShutdownPending = (): void => {
set((state) => {
const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds }
for (const ptyId of exitGuardPtyIds) {
pendingPtyShutdownIds[ptyId] = (pendingPtyShutdownIds[ptyId] ?? 0) + 1
}
return {
suppressedPtyExitIds: {
...state.suppressedPtyExitIds,
...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const))
},
pendingPtyShutdownIds
}
})
}
const settleGuards = (
stoppedPtyIds: ReadonlySet<string>,
clearStoppedTabBindings = false
): void => {
set((state) => {
const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds }
const suppressedPtyExitIds = { ...state.suppressedPtyExitIds }
for (const ptyId of exitGuardPtyIds) {
const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1
if (remainingOwners > 0) {
pendingPtyShutdownIds[ptyId] = remainingOwners
} else {
delete pendingPtyShutdownIds[ptyId]
if (!stoppedPtyIds.has(ptyId)) {
delete suppressedPtyExitIds[ptyId]
}
}
}
if (!clearStoppedTabBindings) {
return { pendingPtyShutdownIds, suppressedPtyExitIds }
}
const ptyIdsByTabId = { ...state.ptyIdsByTabId }
for (const tab of tabs) {
ptyIdsByTabId[tab.id] = (state.ptyIdsByTabId[tab.id] ?? []).filter(
(ptyId) => !stoppedPtyIds.has(ptyId)
)
}
return { ptyIdsByTabId, pendingPtyShutdownIds, suppressedPtyExitIds }
})
}
const rollbackShutdown = (): void => {
if (handlerSnapshots.length > 0) {
restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots)
}
set((state) => {
const suppressedPtyExitIds = { ...state.suppressedPtyExitIds }
const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds }
for (const ptyId of exitGuardPtyIds) {
const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1
if (remainingOwners > 0) {
pendingPtyShutdownIds[ptyId] = remainingOwners
} else {
delete pendingPtyShutdownIds[ptyId]
if (!hasCommittedPtyShutdownSettlement(ptyId)) {
delete suppressedPtyExitIds[ptyId]
}
}
}
return { suppressedPtyExitIds, pendingPtyShutdownIds }
})
const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId))
const committedPtyIds = settledPtyIds.filter(hasCommittedPtyShutdownSettlement)
const rolledBackPtyIds = settledPtyIds.filter(
(ptyId) => !hasCommittedPtyShutdownSettlement(ptyId)
)
markCommittedPtyShutdowns(committedPtyIds)
settleDeferredPtyShutdownExits(committedPtyIds, 'committed')
settleDeferredPtyShutdownExits(rolledBackPtyIds, 'rolled-back')
clearCommittedPtyShutdownSettlements(settledPtyIds)
}
const stopRendererPtys = async (): Promise<{
stoppedPtyIds: string[]
failure?: PromiseRejectedResult
}> => {
const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:'))
const results = await Promise.allSettled(
localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers }))
)
const stoppedPtyIds = [
...(runtimeEnvironmentId
? rendererShutdownPtyIds.filter((ptyId) => ptyId.startsWith('remote:'))
: []),
...localPtyIds.filter((_, index) => results[index]?.status === 'fulfilled')
]
disposeParkedTerminalWatchersForPtyIds(stoppedPtyIds)
return {
stoppedPtyIds,
failure: results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
}
}
const settlePartialRendererStop = (stoppedPtyIds: readonly string[]): void => {
partialRendererStopSettled = true
const stopped = new Set(stoppedPtyIds)
const stoppedSnapshots = handlerSnapshots.filter((snapshot) => stopped.has(snapshot.ptyId))
const failedSnapshots = handlerSnapshots.filter((snapshot) => !stopped.has(snapshot.ptyId))
for (const snapshot of stoppedSnapshots) {
snapshot.commit?.()
}
restorePtyDataHandlersAfterFailedShutdown(failedSnapshots)
noteCommittedPtyShutdownSettlements(stoppedPtyIds)
settleGuards(stopped, true)
const failedPtyIds = exitGuardPtyIds.filter((ptyId) => !stopped.has(ptyId))
markCommittedPtyShutdowns(stoppedPtyIds)
settleDeferredPtyShutdownExits(stoppedPtyIds, 'committed')
settleDeferredPtyShutdownExits(failedPtyIds, 'rolled-back')
clearCommittedPtyShutdownSettlements(exitGuardPtyIds)
}
return {
commitHandlerSnapshots: () => {
for (const snapshot of handlerSnapshots) {
snapshot.commit?.()
}
noteCommittedPtyShutdownSettlements(exitGuardPtyIds)
},
markShutdownPending,
rollbackShutdown,
settlePartialRendererStop,
stopRendererPtys,
unregisterHandlers: () => {
handlerSnapshots = unregisterPtyDataHandlers([...rendererShutdownPtyIds]) ?? []
},
wasPartialRendererStopSettled: () => partialRendererStopSettled
}
}
@@ -0,0 +1,194 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import { clearTransientTerminalState } from '../slices/terminal-helpers'
import {
clearCommittedPtyShutdownSettlements,
markCommittedPtyShutdowns,
settleDeferredPtyShutdownExits
} from '@/components/terminal-pane/pty-shutdown-exit-deferral'
import {
removeSleepingRecordsReplacedByManualWorktreeSleep,
type AgentStatusWorktreeShutdownReason,
type RetainedAgentEntry
} from '../slices/agent-status'
import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function commitTerminalShutdownState({
exitGuardPtyIds,
get,
keepIdentifiers,
retainedCompletionEvidence,
set,
shutdownReason,
sleepingAgentSessionRecords,
sleepingPaneKeys,
tabs,
worktreeId
}: {
exitGuardPtyIds: readonly string[]
get: TerminalStoreGet
keepIdentifiers: boolean
retainedCompletionEvidence: readonly RetainedAgentEntry[]
set: TerminalStoreSet
shutdownReason: AgentStatusWorktreeShutdownReason
sleepingAgentSessionRecords: Record<string, SleepingAgentSessionRecord>
sleepingPaneKeys?: readonly string[]
tabs: readonly TerminalTab[]
worktreeId: string
}): void {
set((state) => {
const tabsByWorktree = keepIdentifiers
? state.tabsByWorktree
: {
...state.tabsByWorktree,
[worktreeId]: (state.tabsByWorktree[worktreeId] ?? []).map((tab, index) =>
clearTransientTerminalState(tab, index)
)
}
const ptyIdsByTabId = {
...state.ptyIdsByTabId,
...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const))
}
const runtimePaneTitlesByTabId = keepIdentifiers
? state.runtimePaneTitlesByTabId
: { ...state.runtimePaneTitlesByTabId }
const suppressedPtyExitIds = {
...state.suppressedPtyExitIds,
...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const))
}
const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds }
for (const ptyId of exitGuardPtyIds) {
const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1
if (remainingOwners > 0) {
pendingPtyShutdownIds[ptyId] = remainingOwners
} else {
delete pendingPtyShutdownIds[ptyId]
}
}
// Sleeping terminals retain restart intent, but a wake can receive a different live PTY id.
const pendingCodexPaneRestartIds = keepIdentifiers
? state.pendingCodexPaneRestartIds
: { ...state.pendingCodexPaneRestartIds }
const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId }
for (const ptyId of exitGuardPtyIds) {
if (!keepIdentifiers) {
delete pendingCodexPaneRestartIds[ptyId]
}
delete codexRestartNoticeByPtyId[ptyId]
}
const pendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId }
const pendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId }
const terminalLayoutsByTabId = { ...state.terminalLayoutsByTabId }
let unreadTerminalTabs = state.unreadTerminalTabs
let unreadTerminalPanes = state.unreadTerminalPanes
let unreadAgentCompletionPanes = state.unreadAgentCompletionPanes
let lastTerminalInputAtByPaneKey = state.lastTerminalInputAtByPaneKey
for (const tab of tabs) {
if (!keepIdentifiers) {
delete runtimePaneTitlesByTabId[tab.id]
}
delete pendingSetupSplitByTabId[tab.id]
delete pendingIssueCommandSplitByTabId[tab.id]
if (unreadTerminalTabs[tab.id]) {
if (unreadTerminalTabs === state.unreadTerminalTabs) {
unreadTerminalTabs = { ...state.unreadTerminalTabs }
}
delete unreadTerminalTabs[tab.id]
}
for (const paneKey of Object.keys(unreadTerminalPanes)) {
if (paneKey.startsWith(`${tab.id}:`)) {
if (unreadTerminalPanes === state.unreadTerminalPanes) {
unreadTerminalPanes = { ...unreadTerminalPanes }
}
delete unreadTerminalPanes[paneKey]
}
}
for (const paneKey of Object.keys(unreadAgentCompletionPanes)) {
if (paneKey.startsWith(`${tab.id}:`)) {
if (unreadAgentCompletionPanes === state.unreadAgentCompletionPanes) {
unreadAgentCompletionPanes = { ...unreadAgentCompletionPanes }
}
delete unreadAgentCompletionPanes[paneKey]
}
}
for (const paneKey of Object.keys(lastTerminalInputAtByPaneKey)) {
if (paneKey.startsWith(`${tab.id}:`)) {
if (lastTerminalInputAtByPaneKey === state.lastTerminalInputAtByPaneKey) {
lastTerminalInputAtByPaneKey = { ...lastTerminalInputAtByPaneKey }
}
delete lastTerminalInputAtByPaneKey[paneKey]
}
}
if (!keepIdentifiers) {
const layout = terminalLayoutsByTabId[tab.id]
if (layout?.ptyIdsByLeafId) {
terminalLayoutsByTabId[tab.id] = { ...layout, ptyIdsByLeafId: {} }
}
}
}
const lastKnownRelayPtyIdByTabId = keepIdentifiers
? state.lastKnownRelayPtyIdByTabId
: { ...state.lastKnownRelayPtyIdByTabId }
if (!keepIdentifiers) {
for (const tab of tabs) {
delete lastKnownRelayPtyIdByTabId[tab.id]
}
}
return {
tabsByWorktree,
ptyIdsByTabId,
lastKnownRelayPtyIdByTabId,
runtimePaneTitlesByTabId,
suppressedPtyExitIds,
pendingPtyShutdownIds,
pendingCodexPaneRestartIds,
codexRestartNoticeByPtyId,
pendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId,
terminalLayoutsByTabId,
...(unreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs } : {}),
...(unreadTerminalPanes !== state.unreadTerminalPanes ? { unreadTerminalPanes } : {}),
...(unreadAgentCompletionPanes !== state.unreadAgentCompletionPanes
? { unreadAgentCompletionPanes }
: {}),
...(lastTerminalInputAtByPaneKey !== state.lastTerminalInputAtByPaneKey
? { lastTerminalInputAtByPaneKey }
: {})
}
})
if (keepIdentifiers) {
set((state) => {
const base =
shutdownReason === 'manual-sleep'
? removeSleepingRecordsReplacedByManualWorktreeSleep(
state.sleepingAgentSessionsByPaneKey,
worktreeId,
sleepingPaneKeys,
sleepingAgentSessionRecords
).records
: state.sleepingAgentSessionsByPaneKey
return {
sleepingAgentSessionsByPaneKey: { ...base, ...sleepingAgentSessionRecords }
}
})
} else {
get().clearSleepingAgentSessionsByWorktree(worktreeId)
}
get().dropAgentStatusByWorktree(worktreeId, {
shutdownReason,
sleepingPaneKeys,
retainedCompletionEvidence
})
get().clearPaneForegroundAgentByWorktree(worktreeId)
const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId))
markCommittedPtyShutdowns(settledPtyIds)
settleDeferredPtyShutdownExits(settledPtyIds, 'committed')
clearCommittedPtyShutdownSettlements(settledPtyIds)
}
@@ -0,0 +1,172 @@
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
type AgentStatusWorktreeShutdownReason
} from '../slices/agent-status'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities'
import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing'
import { createTerminalShutdownGuardController } from './terminal-shutdown-guards'
import { commitTerminalShutdownState } from './terminal-shutdown-state'
type ExactTerminalStopResult = {
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
}
export function createTerminalShutdownActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'shutdownWorktreeTerminals'> {
return {
shutdownWorktreeTerminals: async (worktreeId, opts) => {
const keepIdentifiers = opts?.keepIdentifiers ?? false
const shutdownReason: AgentStatusWorktreeShutdownReason =
opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree')
const tabs = get().tabsByWorktree[worktreeId] ?? []
const rendererShutdownPtyIds = sortedUniquePtyIds(
tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? [])
)
const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds)
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
// Only renderer-bound ids emit pane exit callbacks; raw RPC handles never enter this guard.
const exitGuardPtyIds = rendererShutdownPtyIds
const sleepingAgentSessionRecords = keepIdentifiers
? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, {
paneKeys: opts?.sleepingPaneKeys,
...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}),
...(shutdownReason === 'auto-hibernate-completed-agent'
? { captureMode: 'completed-agent-hibernation' }
: {})
})
: {}
const retainedCompletionEvidence =
shutdownReason === 'auto-hibernate-completed-agent'
? collectHibernatedCompletionEvidenceForWorktree(
get(),
worktreeId,
opts?.sleepingPaneKeys
)
: []
const guards = createTerminalShutdownGuardController({
exitGuardPtyIds,
get,
keepIdentifiers,
rendererShutdownPtyIds,
runtimeEnvironmentId,
set,
tabs
})
// Capture before kill; capture() can publish layout changes consumed by the final commit.
if (keepIdentifiers) {
for (const tab of tabs) {
const capture = shutdownBufferCaptures.get(tab.id)
if (capture) {
try {
capture({ includeLocalBuffers: false })
} catch {
// One unavailable pane must not block capture of the rest of the worktree.
}
}
}
}
if (expectedRuntimePtyIds.length === 0) {
guards.markShutdownPending()
guards.unregisterHandlers()
try {
// Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks.
if (runtimeEnvironmentId && opts?.backendOwnsPtyTeardown !== true) {
await (shutdownReason === 'manual-sleep'
? requestRemoteWorktreeSleep({ environmentId: runtimeEnvironmentId, worktreeId })
: callRuntimeRpc(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stop',
{ worktree: toRuntimeWorktreeSelector(worktreeId) },
{ timeoutMs: 15_000 }
))
}
// Renderer teardown waits for the owner RPC so failures leave bindings retryable.
const rendererStop = await guards.stopRendererPtys()
if (rendererStop.failure) {
guards.settlePartialRendererStop(rendererStop.stoppedPtyIds)
throw rendererStop.failure.reason
}
} catch (error) {
if (!guards.wasPartialRendererStopSettled()) {
guards.rollbackShutdown()
}
throw error
}
} else {
if (!runtimeEnvironmentId) {
throw new Error('missing_runtime_for_exact_terminal_stop')
}
guards.markShutdownPending()
guards.unregisterHandlers()
let stopResult: ExactTerminalStopResult
try {
stopResult = await callRuntimeRpc<ExactTerminalStopResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stopExact',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
expectedPtyIds: expectedRuntimePtyIds,
keepHistory: keepIdentifiers
},
{ timeoutMs: 15_000 }
)
} catch (error) {
guards.rollbackShutdown()
throw error
}
const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds)
const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds)
if (
!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) ||
!equalStringSets(livePtyIds, expectedRuntimePtyIds)
) {
guards.rollbackShutdown()
throw new Error('exact_terminal_stop_mismatch')
}
if (stopResult.postStopVerified !== true) {
guards.rollbackShutdown()
throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified')
}
try {
const rendererStop = await guards.stopRendererPtys()
if (rendererStop.failure) {
guards.settlePartialRendererStop(rendererStop.stoppedPtyIds)
throw rendererStop.failure.reason
}
} catch (error) {
if (!guards.wasPartialRendererStopSettled()) {
guards.rollbackShutdown()
}
throw error
}
}
guards.commitHandlerSnapshots()
commitTerminalShutdownState({
exitGuardPtyIds,
get,
keepIdentifiers,
retainedCompletionEvidence,
set,
shutdownReason,
sleepingAgentSessionRecords,
sleepingPaneKeys: opts?.sleepingPaneKeys,
tabs,
worktreeId
})
}
}
}
@@ -0,0 +1,133 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalStartupQueueActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'queueTabStartupCommand'
| 'queueTabInitialCwd'
| 'consumeTabInitialCwd'
| 'consumeTabStartupCommand'
| 'queueTabSetupSplit'
| 'consumeTabSetupSplit'
| 'queueTabIssueCommandSplit'
| 'consumeTabIssueCommandSplit'
| 'consumePendingSnapshot'
| 'consumePendingColdRestore'
> {
return {
queueTabStartupCommand: (tabId, startup) => {
// Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token.
const launchToken = startup.launchConfig
? (startup.launchToken ?? createBrowserUuid())
: undefined
set((s) => ({
pendingStartupByTabId: {
...s.pendingStartupByTabId,
[tabId]: {
...startup,
...(launchToken ? { launchToken } : {})
}
}
}))
},
queueTabInitialCwd: (tabId, cwd) => {
set((s) => ({
pendingInitialCwdByTabId: {
...s.pendingInitialCwdByTabId,
[tabId]: cwd
}
}))
},
consumeTabInitialCwd: (tabId) => {
const pending = get().pendingInitialCwdByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingInitialCwdByTabId }
delete next[tabId]
return { pendingInitialCwdByTabId: next }
})
return pending
},
consumeTabStartupCommand: (tabId) => {
const pending = get().pendingStartupByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingStartupByTabId }
delete next[tabId]
return { pendingStartupByTabId: next }
})
return pending
},
queueTabSetupSplit: (tabId, startup) => {
set((s) => ({
pendingSetupSplitByTabId: {
...s.pendingSetupSplitByTabId,
[tabId]: startup
}
}))
},
consumeTabSetupSplit: (tabId) => {
const pending = get().pendingSetupSplitByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingSetupSplitByTabId }
delete next[tabId]
return { pendingSetupSplitByTabId: next }
})
return pending
},
queueTabIssueCommandSplit: (tabId, issueCommand) => {
set((s) => ({
pendingIssueCommandSplitByTabId: {
...s.pendingIssueCommandSplitByTabId,
[tabId]: issueCommand
}
}))
},
consumeTabIssueCommandSplit: (tabId) => {
const pending = get().pendingIssueCommandSplitByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingIssueCommandSplitByTabId }
delete next[tabId]
return { pendingIssueCommandSplitByTabId: next }
})
return pending
},
consumePendingSnapshot: (ptyId) => {
const snapshot = get().pendingSnapshotByPtyId[ptyId]
if (!snapshot) {
return null
}
set((s) => {
const next = { ...s.pendingSnapshotByPtyId }
delete next[ptyId]
return { pendingSnapshotByPtyId: next }
})
return snapshot
},
consumePendingColdRestore: (ptyId) => {
const data = get().pendingColdRestoreByPtyId[ptyId]
if (!data) {
return null
}
set((s) => {
const next = { ...s.pendingColdRestoreByPtyId }
delete next[ptyId]
return { pendingColdRestoreByPtyId: next }
})
return data
}
}
}
@@ -0,0 +1,125 @@
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload'
import type {
DirectSshLivePtyBinding,
DirectSshPaneRetryAttempt,
DirectSshPaneRetryHistory
} from '../slices/direct-ssh-terminal-recovery'
import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-contracts'
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { TerminalActions } from './terminal-actions'
export type TerminalState = {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
/** Per-worktree focus survives workspace switches independently of the global active tab. */
activeTabIdByWorktree: Record<string, string | null>
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
unreadTerminalTabs: Record<string, true>
unreadTerminalPanes: Record<string, true>
unreadAgentCompletionPanes: Record<string, true>
/** Scoped exit suppression and reference-counted shutdown ownership prevent teardown races. */
suppressedPtyExitIds: Record<string, true>
pendingPtyShutdownIds: Record<string, number>
pendingCodexPaneRestartIds: Record<string, true>
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice>
directSshPaneRetryByTabId: Record<string, DirectSshPaneRetryAttempt>
directSshLivePtyBindingByTabId: Record<string, DirectSshLivePtyBinding>
directSshPaneRetryHistoryByTabId: Record<string, DirectSshPaneRetryHistory>
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
recentQuickCommandIdByGroup: Record<string, string>
/** Runtime-only claim bridging startup payload consumption until terminal hooks mount. */
automaticAgentResumeClaimsByTabId: Record<string, AutomaticAgentResumeClaim>
nativeChatLaunchPromptByTabId: Record<string, NativeChatLaunchPrompt>
nativeChatLaunchDraftByTabId: Record<string, NativeChatLaunchDraft>
pendingStartupByTabId: Record<
string,
{
command: string
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
initialAgentStatus?: {
agent: TuiAgent
prompt: string
}
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
>
pendingInitialCwdByTabId: Record<string, string>
pendingSetupSplitByTabId: Record<
string,
{
command: string
env?: Record<string, string>
direction: SetupSplitDirection
}
>
pendingIssueCommandSplitByTabId: Record<
string,
{
command: string
env?: Record<string, string>
}
>
tabBarOrderByWorktree: Record<string, string[]>
/** False until global reconnect publishes every deferred wake hint. */
workspaceSessionReady: boolean
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
hydrationSucceeded: boolean
pendingReconnectWorktreeIds: string[]
pendingReconnectTabByWorktree: Record<string, string[]>
/** Prior daemon/relay identities are wake hints; they are not proof of current liveness. */
pendingReconnectPtyIdByTabId: Record<string, string>
/** Retained across relay disconnect after tab.ptyId is cleared so persistence can reattach. */
lastKnownRelayPtyIdByTabId: Record<string, string>
/** Reattach snapshots are consumed once by the pane that receives the replacement PTY. */
pendingSnapshotByPtyId: Record<
string,
{
snapshot: string
cols?: number
rows?: number
isAlternateScreen?: boolean
}
>
pendingColdRestoreByPtyId: Record<
string,
{
scrollback: string
cwd: string
}
>
cacheTimerByKey: Record<string, number | null>
lastTerminalInputAtByPaneKey: Record<string, number>
deferredSshReconnectTargets: string[]
deferredSshSessionIdsByTabId: Record<string, string>
}
export type TerminalSlice = TerminalState & TerminalActions
export type TerminalStoreSet = Parameters<StateCreator<AppState, [], [], AppState>>[0]
export type TerminalStoreGet = Parameters<StateCreator<AppState, [], [], AppState>>[1]
@@ -0,0 +1,126 @@
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalTabAttentionActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'markTerminalTabUnread'
| 'markTerminalPaneUnread'
| 'markAgentCompletionPaneUnread'
| 'clearTerminalTabUnread'
| 'clearTerminalPaneUnread'
| 'setTabCustomTitle'
| 'setTabColor'
> {
return {
markTerminalTabUnread: (tabId) => {
const state = get()
const ownerTab = Object.values(state.tabsByWorktree ?? {})
.flat()
.find((t) => t.id === tabId)
if (!ownerTab) {
return
}
// Why: terminal attention persists until real interaction.
set((s) => {
if (s.unreadTerminalTabs[tabId]) {
return s
}
return { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: true as const } }
})
},
markTerminalPaneUnread: (paneKey) => {
set((s) => {
if (s.unreadTerminalPanes[paneKey]) {
return s
}
return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } }
})
},
markAgentCompletionPaneUnread: (paneKey) => {
set((s) => {
if (s.unreadAgentCompletionPanes[paneKey]) {
return s
}
return {
unreadAgentCompletionPanes: {
...s.unreadAgentCompletionPanes,
[paneKey]: true as const
}
}
})
},
clearTerminalTabUnread: (tabId) => {
set((s) => {
if (!s.unreadTerminalTabs[tabId]) {
return s
}
const copy = { ...s.unreadTerminalTabs }
delete copy[tabId]
return { unreadTerminalTabs: copy }
})
},
clearTerminalPaneUnread: (paneKey) => {
set((s) => {
if (!s.unreadTerminalPanes[paneKey] && !s.unreadAgentCompletionPanes[paneKey]) {
return s
}
const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes }
const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes }
delete nextUnreadTerminalPanes[paneKey]
delete nextUnreadAgentCompletionPanes[paneKey]
return {
unreadTerminalPanes: nextUnreadTerminalPanes,
unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes
}
})
},
setTabCustomTitle: (tabId, title, opts) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t))
}
scheduleRuntimeGraphSync()
return { tabsByWorktree: next }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setTabCustomLabel(item.id, title, opts)
}
},
setTabColor: (tabId, color) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t))
}
return { tabsByWorktree: next }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setUnifiedTabColor(item.id, color)
// Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot.
const state = get()
const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) =>
(state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id)
)
if (
owningWorktreeId &&
resolveTerminalWorktreeRoute(state, owningWorktreeId)?.runtimeEnvironmentId
) {
void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) =>
setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color })
)
}
}
}
}
}
@@ -0,0 +1,68 @@
import type { AppState } from '../types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import {
classifyTerminalRetirementWorktree,
type TerminalTabRetirementPlan
} from '../slices/terminal-tab-retirement'
export function startTerminalTabProviderRetirement({
localPtyTeardownOwnedExternally,
remoteCloseOwnedByHost,
retirementPlan,
state,
tabId
}: {
localPtyTeardownOwnedExternally: boolean
remoteCloseOwnedByHost: boolean
retirementPlan: TerminalTabRetirementPlan
state: AppState
tabId: string
}): void {
const fallbackWorktreeRoute = retirementPlan.worktreeId
? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId)
: { runtimeEnvironmentId: null }
const retirementTasks: Promise<unknown>[] = localPtyTeardownOwnedExternally
? []
: retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId))
const localOrSshTaskCount = retirementTasks.length
if (!remoteCloseOwnedByHost) {
for (const terminal of retirementPlan.runtimeTerminals) {
if (!terminal.environmentId && !fallbackWorktreeRoute) {
continue
}
const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId
retirementTasks.push(
callRuntimeRpc(
environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
'terminal.close',
{ terminal: terminal.handle }
)
)
}
}
if (retirementPlan.unroutablePtyIds.length > 0) {
// Log the worktree shape, never its id, because worktree ids embed absolute paths.
console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', {
tabId,
worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId),
count: retirementPlan.unroutablePtyIds.length
})
}
// Close remains synchronous; provider failures cannot block ownership revocation.
void Promise.allSettled(retirementTasks).then((results) => {
const localOrSshFailures = results
.slice(0, localOrSshTaskCount)
.filter((result) => result.status === 'rejected').length
const runtimeFailures = results
.slice(localOrSshTaskCount)
.filter((result) => result.status === 'rejected').length
if (localOrSshFailures > 0 || runtimeFailures > 0) {
console.warn('[terminal-retirement] provider teardown failed', {
tabId,
localOrSshFailures,
runtimeFailures
})
}
})
}
@@ -0,0 +1,266 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { sweepRetiredTerminalTabState } from '../slices/retired-terminal-tab-state-sweep'
import {
getRecentlyClosedTabPosition,
pushClosedTerminalTabSnapshot,
pushRecentlyClosedTabKind
} from '../slices/recently-closed-tabs'
// Why: use the store-free registry (not terminal-parked-tab-watchers, which imports @/store) to avoid re-entering store creation during this slice's eval.
import { retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry'
import {
buildTerminalTabRetirementPlan,
removeSleepingAgentSessionsForTab
} from '../slices/terminal-tab-retirement'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers'
export function createTerminalTabCloseActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'closeTab'> {
return {
closeTab: (tabId, opts) => {
const closeReason = opts?.reason ?? 'user'
const retiresSession = closeReason === 'user' || closeReason === 'cleanup'
const retirementPlan =
opts?.precomputedRetirementPlan?.tabId === tabId
? opts.precomputedRetirementPlan
: buildTerminalTabRetirementPlan(get(), tabId)
let closingWorktreeId: string | null = null
// Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races.
retireParkedTerminalTab(tabId)
if (retiresSession) {
startTerminalTabProviderRetirement({
localPtyTeardownOwnedExternally: opts?.localPtyTeardownOwnedExternally === true,
remoteCloseOwnedByHost: opts?.remoteCloseOwnedByHost === true,
retirementPlan,
state: get(),
tabId
})
}
set((s) => {
const next = { ...s.tabsByWorktree }
let closedTab: TerminalTab | null = null
let closedWorktreeId: string | null = null
for (const wId of Object.keys(next)) {
const before = next[wId]
const closing = before.find((t) => t.id === tabId)
if (closing) {
closingWorktreeId = wId
// Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below).
if (!closedTab) {
closedTab = closing
closedWorktreeId = wId
}
}
const after = before.filter((t) => t.id !== tabId)
if (after.length !== before.length) {
next[wId] = after
}
}
// Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history.
const closedPosition =
closedWorktreeId && closedTab
? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id)
: undefined
const capturedSnapshot =
closeReason === 'user' &&
opts?.captureRecentlyClosed !== false &&
closedTab &&
closedWorktreeId
? {
...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}),
...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}),
...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}),
...(closedTab.color ? { color: closedTab.color } : {}),
...(closedPosition ? { position: closedPosition } : {})
}
: null
const nextExpanded = { ...s.expandedPaneByTabId }
delete nextExpanded[tabId]
const nextCanExpand = { ...s.canExpandPaneByTabId }
delete nextCanExpand[tabId]
const nextLayouts = { ...s.terminalLayoutsByTabId }
delete nextLayouts[tabId]
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
delete nextPtyIdsByTabId[tabId]
const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId }
delete nextLastKnownRelay[tabId]
const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId }
delete nextDeferredSshSessionIdsByTabId[tabId]
const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId }
delete nextPendingReconnectPtyIdByTabId[tabId]
const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
delete nextRuntimePaneTitlesByTabId[tabId]
const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId }
delete nextDirectSshPaneRetryByTabId[tabId]
const nextDirectSshLivePtyBindingByTabId = {
...s.directSshLivePtyBindingByTabId
}
delete nextDirectSshLivePtyBindingByTabId[tabId]
const nextDirectSshPaneRetryHistoryByTabId = {
...s.directSshPaneRetryHistoryByTabId
}
delete nextDirectSshPaneRetryHistoryByTabId[tabId]
// Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval.
let nextUnreadTerminalTabs = s.unreadTerminalTabs
if (s.unreadTerminalTabs[tabId]) {
nextUnreadTerminalTabs = { ...s.unreadTerminalTabs }
delete nextUnreadTerminalTabs[tabId]
}
let nextUnreadTerminalPanes = s.unreadTerminalPanes
for (const paneKey of Object.keys(s.unreadTerminalPanes)) {
if (paneKey.startsWith(`${tabId}:`)) {
if (nextUnreadTerminalPanes === s.unreadTerminalPanes) {
nextUnreadTerminalPanes = { ...s.unreadTerminalPanes }
}
delete nextUnreadTerminalPanes[paneKey]
}
}
let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes
for (const paneKey of Object.keys(s.unreadAgentCompletionPanes)) {
if (paneKey.startsWith(`${tabId}:`)) {
if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) {
nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes }
}
delete nextUnreadAgentCompletionPanes[paneKey]
}
}
const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey }
for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) {
if (paneKey.startsWith(`${tabId}:`)) {
delete nextLastTerminalInputAtByPaneKey[paneKey]
}
}
const nextSleepingAgentSessionsByPaneKey = retiresSession
? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId)
: s.sleepingAgentSessionsByPaneKey
const nextPendingStartupByTabId = { ...s.pendingStartupByTabId }
delete nextPendingStartupByTabId[tabId]
const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId }
delete nextAutomaticAgentResumeClaimsByTabId[tabId]
const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId }
delete nextNativeChatLaunchPromptByTabId[tabId]
const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId }
delete nextNativeChatLaunchDraftByTabId[tabId]
const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId }
delete nextPendingInitialCwdByTabId[tabId]
const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId }
delete nextPendingSetupSplitByTabId[tabId]
const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId }
delete nextPendingIssueCommandSplitByTabId[tabId]
const nextCacheTimer = { ...s.cacheTimerByKey }
// Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab.
for (const key of Object.keys(nextCacheTimer)) {
if (key.startsWith(`${tabId}:`)) {
delete nextCacheTimer[key]
}
}
// Why: keep activeTabIdByWorktree in sync when closing a background-worktree tab, else the stale remembered tab falls back to tabs[0] on switch.
const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree }
for (const [wId, tabs] of Object.entries(next)) {
if (nextActiveTabIdByWorktree[wId] === tabId) {
nextActiveTabIdByWorktree[wId] = tabs[0]?.id ?? null
}
}
// Why: keep tabBarOrderByWorktree in sync so stale terminal IDs don't linger and shift positions on later tab operations.
const nextTabBarOrderByWorktree: Record<string, string[]> = {
...s.tabBarOrderByWorktree
}
for (const wId of Object.keys(nextTabBarOrderByWorktree)) {
const order = nextTabBarOrderByWorktree[wId]
if (order?.includes(tabId)) {
nextTabBarOrderByWorktree[wId] = order.filter((entryId) => entryId !== tabId)
}
}
// Why: clean up unconsumed snapshot/cold-restore data (e.g. tab closed before TerminalPane mounted) to prevent unbounded store growth across restarts.
let nextSnapshots = s.pendingSnapshotByPtyId
let nextColdRestores = s.pendingColdRestoreByPtyId
const closingPtyIds = new Set([
...retirementPlan.localOrSshPtyIds,
...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId),
...retirementPlan.cleanupOnlyPtyIds,
...retirementPlan.unroutablePtyIds
])
for (const closingId of closingPtyIds) {
if (closingId in nextSnapshots) {
nextSnapshots = { ...nextSnapshots }
delete nextSnapshots[closingId]
}
if (closingId in nextColdRestores) {
nextColdRestores = { ...nextColdRestores }
delete nextColdRestores[closingId]
}
}
return {
tabsByWorktree: next,
activeTabId: s.activeTabId === tabId ? null : s.activeTabId,
activeTabIdByWorktree: nextActiveTabIdByWorktree,
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId,
pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId,
directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId,
directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId,
...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey
? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey }
: {}),
// Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts.
...(nextUnreadTerminalTabs !== s.unreadTerminalTabs
? { unreadTerminalTabs: nextUnreadTerminalTabs }
: {}),
...(nextUnreadTerminalPanes !== s.unreadTerminalPanes
? { unreadTerminalPanes: nextUnreadTerminalPanes }
: {}),
...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes
? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes }
: {}),
lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey,
expandedPaneByTabId: nextExpanded,
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId,
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
cacheTimerByKey: nextCacheTimer,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingSnapshotByPtyId: nextSnapshots,
pendingColdRestoreByPtyId: nextColdRestores,
...(capturedSnapshot && closedWorktreeId
? {
recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot(
s.recentlyClosedTerminalTabsByWorktree,
closedWorktreeId,
capturedSnapshot
),
recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind(
s.recentlyClosedTabKindsByWorktree,
closedWorktreeId,
'terminal'
)
}
: {})
}
})
// Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry.
sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId)
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
)
if (workspaceItem) {
get().closeUnifiedTab(workspaceItem.id, {
recordInteraction: opts?.recordInteraction,
terminalRetirementHandled: true
})
}
}
}
}
}
@@ -0,0 +1,245 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id'
import { emptyLayoutSnapshot } from '../slices/terminal-helpers'
import {
buildOrphanTerminalCleanupPatch,
getOrphanTerminalIds
} from '../slices/terminal-orphan-helpers'
import {
dedupeTabOrder,
ensureGroup,
findTabByEntityInGroup,
pushRecentTabId,
sanitizeRecentTabIds,
updateGroup
} from '../slices/tab-group-state'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import {
getRemoteConnectionIdForWorktree,
resolveCreatedTabShellOverride,
worktreeUsesWslPath
} from './terminal-workspace-routing'
export function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set<number>()
for (const tab of tabs) {
const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title)
if (!match) {
continue
}
usedOrdinals.add(Number(match[1]))
}
let nextOrdinal = 1
while (usedOrdinals.has(nextOrdinal)) {
nextOrdinal += 1
}
return nextOrdinal
}
export function createTerminalTabCreationActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'createTab'> {
return {
createTab: (worktreeId, targetGroupId, shellOverride, options) => {
let tab!: TerminalTab
set((s) => {
const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId)
const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds)
const existing = (s.tabsByWorktree[worktreeId] ?? []).filter(
(entry) => !orphanTerminalIds.has(entry.id)
)
// Why: honor a caller-supplied tab id but mint on collision — aliasing two PTYs to one id corrupts agent-status routing. See docs/cli-terminal-hook-pane-key.md.
// Why: only honor a non-empty trimmed hint; useIpcEvents spreads `id` whenever tabId !== undefined, so a stray '' would break paneKey routing.
const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : ''
const hintedId =
trimmedHint.length > 0 && isValidHostTerminalTabId(trimmedHint) ? trimmedHint : undefined
const idCollides =
hintedId !== undefined &&
Object.values(s.tabsByWorktree).some((tabs) =>
tabs.some((entry) => entry.id === hintedId)
)
if (idCollides) {
console.warn(
`[createTab] tabId hint ${hintedId} already exists; minting a fresh id (hook attribution will degrade for this terminal)`
)
}
const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid()
const shouldActivate = options?.activate !== false
const nextOrdinal = getNextTerminalOrdinal(existing)
const defaultTitle = `Terminal ${nextOrdinal}`
const quickCommandLabel = options?.quickCommandLabel?.trim()
const startupCwd = options?.startupCwd
const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId)
const isRemoteWorktree = Boolean(remoteConnectionId)
const isWslWorktree = worktreeUsesWslPath(s, worktreeId)
const createdShellOverride = resolveCreatedTabShellOverride(
shellOverride,
s.settings?.terminalWindowsShell,
// Why: SSH PTYs ignore local Windows shell selection; a local shell icon would mislabel a remote terminal.
isRemoteWorktree,
remoteConnectionId
? ((s.sshConnectionStates.get(remoteConnectionId)
?.remotePlatform as NodeJS.Platform | null) ?? null)
: null,
// Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe.
isWslWorktree,
isRemoteWorktree || options?.forceHostRuntime
? undefined
: getLocalProjectExecutionRuntimeContext(s, worktreeId)
)
tab = {
id,
// Why: CLI-created background sessions already own a PTY, so reveal attaches instead of spawning a duplicate.
ptyId: options?.initialPtyId ?? null,
worktreeId,
// Why: reuse the lowest free ordinal so a fresh terminal stays "Terminal 1" after older tabs close, not a monotonic counter.
title: defaultTitle,
defaultTitle,
...(quickCommandLabel ? { quickCommandLabel } : {}),
customTitle: null,
color: null,
sortOrder: existing.length,
createdAt: Date.now(),
...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}),
...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}),
...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}),
...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}),
// Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click.
...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {})
}
const validTargetGroupId =
targetGroupId &&
s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId)
? targetGroupId
: undefined
const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup(
s.groupsByWorktree,
s.activeGroupIdByWorktree,
worktreeId,
validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId]
)
const nextActiveGroupIdByWorktree =
shouldActivate && validTargetGroupId
? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId }
: activeGroupIdByWorktree
const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? []
const existingTerminalTab = findTabByEntityInGroup(
s.unifiedTabsByWorktree,
worktreeId,
group.id,
id,
'terminal'
)
const groupsForWorktree = groupsByWorktree[worktreeId] ?? []
const cleanedGroups =
orphanTerminalIds.size === 0
? groupsForWorktree
: groupsForWorktree.map((entry) => {
// Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus.
const tabOrder = dedupeTabOrder(entry.tabOrder).filter(
(tabId) => !orphanTerminalIds.has(tabId)
)
const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder)
const replacedActiveTabId = Boolean(
entry.activeTabId && orphanTerminalIds.has(entry.activeTabId)
)
const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null
const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId
return {
...entry,
activeTabId,
tabOrder,
recentTabIds:
replacedActiveTabId && activeTabId
? pushRecentTabId(recentTabIds, activeTabId)
: recentTabIds
}
})
const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group
const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter(
(tabId) => !orphanTerminalIds.has(tabId)
)
const unifiedTab = existingTerminalTab ?? {
id,
entityId: id,
groupId: group.id,
worktreeId,
contentType: 'terminal' as const,
label: tab.title,
...(tab.quickCommandLabel?.trim()
? { quickCommandLabel: tab.quickCommandLabel.trim() }
: {}),
customLabel: tab.customTitle,
color: tab.color,
sortOrder: cleanedGroupOrder.length,
createdAt: tab.createdAt,
// Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode.
...(options?.viewMode ? { viewMode: options.viewMode } : {})
}
const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id])
const nextRecent = shouldActivate
? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id)
: sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder)
const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId]
const cleanedGroupActiveTabId =
cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId)
? cleanedTargetGroup.activeTabId
: null
const nextActiveTabIdForWorktree = shouldActivate
? tab.id
: (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id)
return {
...orphanCleanupPatch,
tabsByWorktree: {
...orphanCleanupPatch.tabsByWorktree,
[worktreeId]: [...existing, tab]
},
// Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host.
unifiedTabsByWorktree: {
...s.unifiedTabsByWorktree,
[worktreeId]: existingTerminalTab
? existingUnifiedTabs
: [...existingUnifiedTabs, unifiedTab]
},
groupsByWorktree: {
...groupsByWorktree,
[worktreeId]: updateGroup(cleanedGroups, {
...cleanedTargetGroup,
activeTabId: shouldActivate
? unifiedTab.id
: (cleanedGroupActiveTabId ?? unifiedTab.id),
tabOrder: nextGroupOrder,
recentTabIds: nextRecent
})
},
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
layoutByWorktree: {
...s.layoutByWorktree,
[worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id }
},
activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId,
activeTabIdByWorktree: {
...orphanCleanupPatch.activeTabIdByWorktree,
[worktreeId]: nextActiveTabIdForWorktree
},
ptyIdsByTabId: {
...orphanCleanupPatch.ptyIdsByTabId,
[tab.id]: options?.initialPtyId ? [options.initialPtyId] : []
},
terminalLayoutsByTabId: {
...orphanCleanupPatch.terminalLayoutsByTabId,
[tab.id]: emptyLayoutSnapshot()
}
}
})
const shouldRecordInteraction =
options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId)
if (shouldRecordInteraction) {
get().recordFeatureInteraction?.('terminal-tabs')
}
return tab
}
}
}
@@ -0,0 +1,114 @@
import { resolveActiveTabOwnerWorktreeId } from '../slices/active-tab-owner-worktree'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalTabNavigationActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
'reorderTabs' | 'setTabBarOrder' | 'setActiveTab' | 'setActiveTabForWorktree'
> {
return {
reorderTabs: (worktreeId, tabIds) => {
set((s) => {
const tabs = s.tabsByWorktree[worktreeId] ?? []
const tabMap = new Map(tabs.map((t) => [t.id, t]))
const orderedSet = new Set(tabIds)
const missingTabs = tabs.filter((t) => !orderedSet.has(t.id))
const reordered = [
...tabIds.map((id) => tabMap.get(id)!).filter(Boolean),
...missingTabs
].map((tab, i) => ({ ...tab, sortOrder: i }))
return {
tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered }
}
})
},
setTabBarOrder: (worktreeId, order) => {
set((s) => {
// Update unified visual order
const newTabBarOrder = { ...s.tabBarOrderByWorktree, [worktreeId]: order }
// Keep terminal tab sortOrder in sync for persistence
const tabs = s.tabsByWorktree[worktreeId]
if (!tabs) {
return { tabBarOrderByWorktree: newTabBarOrder }
}
const tabMap = new Map(tabs.map((t) => [t.id, t]))
// Extract terminal IDs in their new relative order
const terminalIdsInOrder = order.filter((id) => tabMap.has(id))
const orderedSet = new Set(terminalIdsInOrder)
const missingTabs = tabs.filter((t) => !orderedSet.has(t.id))
const updatedTabs = [
...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean),
...missingTabs
].map((tab, i) => ({ ...tab, sortOrder: i }))
return {
tabBarOrderByWorktree: newTabBarOrder,
tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs }
}
})
},
setActiveTab: (tabId) => {
let tabOwnerWorktreeId: string | null = null
set((s) => {
// Why: clear bells only for visible tabs; background bells must persist.
tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId(
s.tabsByWorktree,
s.activeWorktreeId,
tabId
)
const isActiveWorktreeTab =
tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId
const nextUnreadTerminalTabs =
isActiveWorktreeTab && s.unreadTerminalTabs[tabId]
? (() => {
const copy = { ...s.unreadTerminalTabs }
delete copy[tabId]
return copy
})()
: s.unreadTerminalTabs
// Why: activeTabId marks the visible tab, so background bells remain unread.
return {
activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId,
// Why: a redundant activation must not reallocate this map — Terminal's
// active-terminal repair effect depends on it, so a re-activation that
// can't converge activeTabId (tab id reused by an earlier-scanned
// worktree) would otherwise re-trigger itself into React error #185.
activeTabIdByWorktree:
tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId
? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId }
: s.activeTabIdByWorktree,
unreadTerminalTabs: nextUnreadTerminalTabs
}
})
const state = get()
const ownerUnifiedTabs =
tabOwnerWorktreeId !== null &&
Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId)
? state.unifiedTabsByWorktree[tabOwnerWorktreeId]
: []
// Why: a duplicated entity id must activate the same owner chosen above.
const item =
ownerUnifiedTabs.find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
) ??
Object.values(state.unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
state.activateTab(
item.id,
tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined
)
}
},
setActiveTabForWorktree: (worktreeId, tabId) => {
set((s) => ({
activeTabIdByWorktree: {
...s.activeTabIdByWorktree,
[worktreeId]: tabId
}
}))
}
}
}
@@ -0,0 +1,203 @@
import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import {
applyGeneratedTabTitleUpdates,
applyTerminalTabTitleUpdates
} from '../slices/terminal-tab-title-batch'
import { getTabIdFromPaneKey, getTerminalTabOwnerWorktreeId } from './terminal-pty-identities'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
export function createTerminalTabPresentationActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<
TerminalSlice,
| 'updateTabTitle'
| 'updateTabTitles'
| 'setAiVaultTabTitle'
| 'setGeneratedTabTitleFromAgentPrompt'
| 'setGeneratedTabTitlesFromAgentPrompts'
| 'clearTabLaunchAgent'
| 'setRuntimePaneTitle'
| 'clearRuntimePaneTitle'
> {
return {
updateTabTitle: (tabId, title) => {
set((state) => {
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId)
const ownerByTabId = ownerWorktreeId
? new Map([[tabId, ownerWorktreeId]])
: new Map<string, string>()
const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }], ownerByTabId)
if (result.runtimeGraphChanged) {
scheduleRuntimeGraphSync()
}
return result.patch ?? state
})
},
updateTabTitles: (updates) => {
if (updates.length === 0) {
return
}
set((state) => {
const result = applyTerminalTabTitleUpdates(state, updates)
if (result.runtimeGraphChanged) {
scheduleRuntimeGraphSync()
}
return result.patch ?? state
})
},
setAiVaultTabTitle: (tabId, aiVaultTitle) => {
set((s) => {
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return s
}
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
const current = tabs.find((tab) => tab.id === tabId)
const sameTitle =
current?.aiVaultTitle?.agent === aiVaultTitle?.agent &&
current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId &&
current?.aiVaultTitle?.title === aiVaultTitle?.title
if (!current || sameTitle) {
return s
}
const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab))
const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
const nextUnifiedTabs = unifiedTabs.map((tab) =>
tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab
)
scheduleRuntimeGraphSync()
return {
tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs },
unifiedTabsByWorktree: {
...s.unifiedTabsByWorktree,
[ownerWorktreeId]: nextUnifiedTabs
}
}
})
},
setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => {
// Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title.
const state = get()
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) {
return
}
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return
}
const tabs = state.tabsByWorktree[ownerWorktreeId] ?? []
const currentTab = tabs.find((tab) => tab.id === tabId)
if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) {
return
}
const existingGeneratedTitle = currentTab.generatedTitle?.trim()
if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) {
return
}
set((latestState) => {
const result = applyGeneratedTabTitleUpdates(
latestState,
[{ paneKey, prompt, options }],
new Map([[tabId, ownerWorktreeId]])
)
if (result.runtimeGraphChanged) {
scheduleRuntimeGraphSync()
}
return result.patch ?? latestState
})
},
setGeneratedTabTitlesFromAgentPrompts: (updates) => {
if (updates.length === 0) {
return
}
set((state) => {
const result = applyGeneratedTabTitleUpdates(state, updates)
if (result.runtimeGraphChanged) {
scheduleRuntimeGraphSync()
}
return result.patch ?? state
})
},
clearTabLaunchAgent: (tabId) => {
set((s) => {
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return s
}
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
const tabIndex = tabs.findIndex((t) => t.id === tabId)
const currentTab = tabs[tabIndex]
if (!currentTab?.launchAgent) {
return s
}
const { launchAgent: _launchAgent, ...tabWithoutLaunchAgent } = currentTab
void _launchAgent
const nextTabs = [...tabs]
nextTabs[tabIndex] = tabWithoutLaunchAgent
scheduleRuntimeGraphSync()
return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } }
})
},
setRuntimePaneTitle: (tabId, paneId, title) => {
set((s) => {
const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {}
const prevTitle = currentByPane[paneId]
if (prevTitle === title) {
return s
}
if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) {
return s
}
// Why: re-sort hookless title changes only when their activity classification changes.
const classificationChanged =
classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title)
// Why: skip active-worktree remount side effects and orphaned panes.
const ownerWorktreeId = classificationChanged
? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
: null
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive
return {
runtimePaneTitlesByTabId: {
...s.runtimePaneTitlesByTabId,
[tabId]: { ...currentByPane, [paneId]: title }
},
...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {})
}
})
},
clearRuntimePaneTitle: (tabId, paneId) => {
set((s) => {
const currentByPane = s.runtimePaneTitlesByTabId[tabId]
if (!currentByPane || !(paneId in currentByPane)) {
return s
}
const prevTitle = currentByPane[paneId]
const nextByPane = { ...currentByPane }
delete nextByPane[paneId]
const next = { ...s.runtimePaneTitlesByTabId }
if (Object.keys(nextByPane).length > 0) {
next[tabId] = nextByPane
} else {
delete next[tabId]
}
// Why: clearing a classified title changes the smart-sort title-heuristic verdict, so it needs a re-sort. See setRuntimePaneTitle.
const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null
// Why: same active-worktree gate as setRuntimePaneTitle — click-driven teardown clears must not re-rank the sidebar; skip when owner is missing (orphaned).
const ownerWorktreeId = hadClassification
? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
: null
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive
return {
runtimePaneTitlesByTabId: next,
...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {})
}
})
}
}
}
@@ -0,0 +1,115 @@
import type { AppState } from '../types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime'
import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell'
import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
export function isWindowsRendererRuntime(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
}
export function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean {
return (
shell === 'powershell.exe' ||
shell === 'pwsh.exe' ||
shell === 'cmd.exe' ||
shell === 'wsl.exe' ||
shell === WINDOWS_GIT_BASH_SHELL
)
}
export function resolveCreatedTabShellOverride(
explicitShellOverride: string | undefined,
defaultWindowsShell: string | undefined,
isRemoteWorktree: boolean,
remotePlatform: NodeJS.Platform | null,
isWslWorktree: boolean,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): string | undefined {
if (isRemoteWorktree) {
if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) {
return explicitShellOverride
}
return undefined
}
if (isWindowsRendererRuntime()) {
return resolveLocalWindowsTerminalShellOverrideForTab({
explicitShellOverride,
defaultWindowsShell,
isWslWorktree,
projectRuntime
})
}
if (explicitShellOverride !== undefined) {
return explicitShellOverride
}
return undefined
}
export function worktreeUsesWslPath(
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
worktreeId: string
): boolean {
const parsed = parseWorkspaceKey(worktreeId)
if (parsed?.type === 'folder') {
const folderWorkspace = state.folderWorkspaces.find(
(workspace) => workspace.id === parsed.folderWorkspaceId
)
return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
return worktree ? isWslUncPath(worktree.path) : false
}
export function worktreeUsesRemoteConnection(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
worktreeId: string
): boolean {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId))
}
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
if (directRepo) {
return Boolean(directRepo.connectionId)
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
return Boolean(repo?.connectionId)
}
export function getRemoteConnectionIdForWorktree(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
worktreeId: string
): string | null {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null
}
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
if (directRepo) {
return directRepo.connectionId?.trim() || null
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
return repo?.connectionId?.trim() || null
}
export function resolveTerminalStopRuntimeEnvironmentId(
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
worktreeId: string
): string | null {
return getRuntimeEnvironmentIdForWorktree(state, worktreeId)
}
@@ -0,0 +1,222 @@
import type { AppState } from '../types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { resolveAgentPaneAuthorityKey } from '../slices/agent-pane-authority'
import type { HydrateWorkspaceSessionOptions } from './terminal-contracts'
export type WorkspaceHydrationPatch = Pick<
AppState,
| 'activeRepoId'
| 'activeWorktreeId'
| 'activeWorkspaceKey'
| 'activeWorkspaceExecutionHostId'
| 'activeTabId'
| 'activeTabIdByWorktree'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
| 'repos'
| 'tabsByWorktree'
| 'worktreesByRepo'
| 'lastVisitedAtByWorktreeId'
| 'defaultTerminalTabsAppliedByWorktreeId'
| 'automaticAgentResumeClaimsByTabId'
| 'sleepingAgentSessionsByPaneKey'
| 'pendingReconnectWorktreeIds'
| 'pendingReconnectTabByWorktree'
| 'pendingReconnectPtyIdByTabId'
| 'everActivatedWorktreeIds'
| 'worktreeNavHistory'
| 'worktreeNavHistoryIndex'
| 'ptyIdsByTabId'
| 'terminalLayoutsByTabId'
>
export function replaceHydratedRecordKeys<T>(
current: Record<string, T>,
hydrated: Record<string, T>,
replaceKeys: ReadonlySet<string>
): Record<string, T> {
return {
...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))),
...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key)))
}
}
export type TerminalLayoutPtyOwnershipTransfer = {
removedLeafId: string
retainedLeafId: string
ptyId: string
}
export function transferNormalizedTerminalLayoutPtyOwnership(
state: Pick<AppState, 'terminalLayoutsByTabId' | 'transferAgentPaneAuthority'>,
tabId: string,
transfers: readonly TerminalLayoutPtyOwnershipTransfer[]
): void {
if (transfers.length === 0) {
return
}
const ptyIdsOwnedByOtherTabs = new Set<string>()
for (const [candidateTabId, layout] of Object.entries(state.terminalLayoutsByTabId)) {
if (candidateTabId === tabId) {
continue
}
for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) {
ptyIdsOwnedByOtherTabs.add(ptyId)
}
}
for (const { removedLeafId, retainedLeafId, ptyId } of transfers) {
if (
!isTerminalLeafId(removedLeafId) ||
!isTerminalLeafId(retainedLeafId) ||
ptyIdsOwnedByOtherTabs.has(ptyId)
) {
continue
}
const fromPaneKey = makePaneKey(tabId, removedLeafId)
const currentOwner = parsePaneKey(resolveAgentPaneAuthorityKey(fromPaneKey))
if (currentOwner && currentOwner.tabId !== tabId) {
continue
}
state.transferAgentPaneAuthority({
fromPaneKey,
toPaneKey: makePaneKey(tabId, retainedLeafId),
ptyId
})
}
}
export function targetScopedWorkspaceHydrationPatch(
state: AppState,
hydrated: WorkspaceHydrationPatch,
session: WorkspaceSessionState,
options: HydrateWorkspaceSessionOptions
): Partial<AppState> {
const workspaceKeys = new Set(options.replaceWorkspaceKeys)
const targetTabIds = new Set(
[...workspaceKeys].flatMap((workspaceKey) => [
...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id),
...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)
])
)
const retainedTargetTabIds = new Set(
[...workspaceKeys].flatMap((workspaceKey) =>
(hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)
)
)
const deletedTargetTabIds = new Set(
[...workspaceKeys]
.flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id))
.filter((tabId) => !retainedTargetTabIds.has(tabId))
)
const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys(
state.pendingReconnectPtyIdByTabId,
{},
targetTabIds
)
const authority = options.directSshAuthority
if (authority) {
for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) {
if (!workspaceKeys.has(workspaceKey)) {
continue
}
for (const tab of session.tabsByWorktree[workspaceKey] ?? []) {
// Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns.
if (!retainedTargetTabIds.has(tab.id)) {
continue
}
const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId
if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) {
pendingReconnectPtyIdByTabId[tab.id] = ptyId
}
}
}
}
const activeOutsideScope =
state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId)
const sleepingAgentSessionsByPaneKey = Object.fromEntries([
...Object.entries(state.sleepingAgentSessionsByPaneKey).filter(
([, record]) => !workspaceKeys.has(record.worktreeId)
),
...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) =>
workspaceKeys.has(record.worktreeId)
)
])
const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds)
for (const workspaceKey of hydrated.everActivatedWorktreeIds) {
if (workspaceKeys.has(workspaceKey)) {
everActivatedWorktreeIds.add(workspaceKey)
}
}
return {
activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId,
activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId,
activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey,
activeWorkspaceExecutionHostId: activeOutsideScope
? state.activeWorkspaceExecutionHostId
: hydrated.activeWorkspaceExecutionHostId,
activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId,
activeTabIdByWorktree: replaceHydratedRecordKeys(
state.activeTabIdByWorktree,
hydrated.activeTabIdByWorktree,
workspaceKeys
),
tabsByWorktree: replaceHydratedRecordKeys(
state.tabsByWorktree,
hydrated.tabsByWorktree,
workspaceKeys
),
lastVisitedAtByWorktreeId: replaceHydratedRecordKeys(
state.lastVisitedAtByWorktreeId,
hydrated.lastVisitedAtByWorktreeId,
workspaceKeys
),
defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys(
state.defaultTerminalTabsAppliedByWorktreeId,
hydrated.defaultTerminalTabsAppliedByWorktreeId,
workspaceKeys
),
automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys(
state.automaticAgentResumeClaimsByTabId,
hydrated.automaticAgentResumeClaimsByTabId,
targetTabIds
),
sleepingAgentSessionsByPaneKey,
pendingReconnectWorktreeIds: [
...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)),
...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key))
],
pendingReconnectTabByWorktree: replaceHydratedRecordKeys(
state.pendingReconnectTabByWorktree,
hydrated.pendingReconnectTabByWorktree,
workspaceKeys
),
pendingReconnectPtyIdByTabId,
everActivatedWorktreeIds,
directSshPaneRetryByTabId: replaceHydratedRecordKeys(
state.directSshPaneRetryByTabId,
{},
deletedTargetTabIds
),
directSshLivePtyBindingByTabId: replaceHydratedRecordKeys(
state.directSshLivePtyBindingByTabId,
{},
deletedTargetTabIds
),
directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys(
state.directSshPaneRetryHistoryByTabId,
{},
deletedTargetTabIds
),
ptyIdsByTabId: replaceHydratedRecordKeys(
state.ptyIdsByTabId,
hydrated.ptyIdsByTabId,
targetTabIds
),
terminalLayoutsByTabId: replaceHydratedRecordKeys(
state.terminalLayoutsByTabId,
hydrated.terminalLayoutsByTabId,
targetTabIds
)
}
}
@@ -0,0 +1,209 @@
import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
folderWorkspaceKey,
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../shared/workspace-scope'
import { buildByIdIndex } from '../slices/worktree-by-id-index'
import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from '../slices/degraded-repo-worktree-validity'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { buildRuntimeSessionPlaceholders } from './workspace-terminal-placeholders'
import {
targetScopedWorkspaceHydrationPatch,
transferNormalizedTerminalLayoutPtyOwnership,
type TerminalLayoutPtyOwnershipTransfer,
type WorkspaceHydrationPatch
} from './workspace-terminal-hydration-patch'
import { buildWorkspaceTerminalRowPlan } from './workspace-terminal-row-plan'
import { buildWorkspaceTerminalReconnectPlan } from './workspace-terminal-reconnect-plan'
import { buildWorkspaceTerminalLayoutPlan } from './workspace-terminal-layout-plan'
import { addHydratedSshWorktreePlaceholders } from './workspace-terminal-ssh-placeholders'
export function createWorkspaceTerminalHydrationActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'hydrateWorkspaceSession'> {
return {
hydrateWorkspaceSession: (session, options) => {
const ownershipTransferTabIds = options?.replaceWorkspaceKeys
? new Set(
options.replaceWorkspaceKeys.flatMap((workspaceKey) =>
(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)
)
)
: null
const ownershipTransfersByTabId = new Map<string, TerminalLayoutPtyOwnershipTransfer[]>()
set((s) => {
const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({
repos: s.repos,
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
worktreesByRepo: s.worktreesByRepo
})
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
{
repos: runtimeSessionPlaceholders.repos,
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo,
detectedWorktreesByRepo: s.detectedWorktreesByRepo
},
collectPersistedWorktreeIdsForSessionHydration(session)
)
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
// Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart.
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
const {
canonicalTabIdBySubsumedTabId,
reconnectPtyIdByRetainedTabId,
releasedPtyIdsByTabId,
sleepingAgentSessionsByPaneKey,
tabsByWorktree,
validTabIds
} = buildWorkspaceTerminalRowPlan(session, validWorktreeIds, options)
const fallbackActiveWorktreeId =
!session.activeWorktreeId &&
session.activeRepoId &&
knownRepoIds.has(session.activeRepoId)
? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find(
(worktree) => worktree.isMainWorktree
)?.id ??
runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
null)
: null
const activeWorktreeId = (() => {
if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) {
return session.activeWorktreeId
}
// Why: a workspace with no tabs is still valid; fall back from the active repo to avoid a blank landing screen when tabs were pruned or never created.
return fallbackActiveWorktreeId
})()
const activeWorkspaceKey: WorkspaceKey | null =
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
? session.activeWorkspaceKey
: activeWorktreeId
? parseWorkspaceKey(activeWorktreeId)
? (activeWorktreeId as WorkspaceKey)
: worktreeWorkspaceKey(activeWorktreeId)
: null
const activeWorkspaceExecutionHostId =
activeWorkspaceKey && session.activeWorkspaceExecutionHostId
? session.activeWorkspaceExecutionHostId
: null
// Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app
// restarts with no active terminal even though the same session is still mounted.
const restoredActiveTabId = session.activeTabId
? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId)
: null
const activeTabId =
restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null
const activeRepoId =
session.activeRepoId &&
runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId)
? session.activeRepoId
: null
const {
pendingReconnectPtyIdByTabId,
pendingReconnectTabByWorktree,
pendingReconnectWorktreeIds
} = buildWorkspaceTerminalReconnectPlan({
reconnectPtyIdByRetainedTabId,
releasedPtyIdsByTabId,
repos: runtimeSessionPlaceholders.repos,
session,
validTabIds,
validWorktreeIds,
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo
})
// Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions.
let activeTabIdByWorktree: Record<string, string | null> = {}
if (session.activeTabIdByWorktree) {
for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) {
if (!validWorktreeIds.has(wId) || !tabId) {
continue
}
// Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there
// instead of forgetting which terminal the workspace last focused.
const restored = validTabIds.has(tabId)
? tabId
: canonicalTabIdBySubsumedTabId.get(tabId)
if (restored && validTabIds.has(restored)) {
activeTabIdByWorktree[wId] = restored
}
}
} else {
// Legacy sessions: best-effort derivation
if (activeWorktreeId && activeTabId) {
activeTabIdByWorktree[activeWorktreeId] = activeTabId
}
for (const [wId, tabs] of Object.entries(tabsByWorktree)) {
if (!activeTabIdByWorktree[wId] && tabs.length > 0) {
activeTabIdByWorktree[wId] = tabs[0].id
}
}
}
const worktreesByRepo = addHydratedSshWorktreePlaceholders(
runtimeSessionPlaceholders.repos,
runtimeSessionPlaceholders.worktreesByRepo,
tabsByWorktree
)
// Why: record restored active worktrees to avoid suppressing later real activity.
const nextEverActivated = new Set(s.everActivatedWorktreeIds)
if (activeWorktreeId) {
nextEverActivated.add(activeWorktreeId)
}
// Why indexed: the layout map below looks up a tab per persisted layout, and
// re-flattening tabsByWorktree per entry is O(tabs x layouts).
const allTabs = Object.values(tabsByWorktree).flat()
const tabById = buildByIdIndex(allTabs)
const hydrated: WorkspaceHydrationPatch = {
activeRepoId,
activeWorktreeId,
activeWorkspaceKey,
activeWorkspaceExecutionHostId,
activeTabId,
activeTabIdByWorktree,
restoredRuntimeHostIdByWorkspaceSessionKey:
options?.runtimeHostIdByWorkspaceSessionKey ?? {},
repos: runtimeSessionPlaceholders.repos,
tabsByWorktree,
worktreesByRepo,
// Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo.
lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {},
defaultTerminalTabsAppliedByWorktreeId:
session.defaultTerminalTabsAppliedByWorktreeId ?? {},
automaticAgentResumeClaimsByTabId: {},
sleepingAgentSessionsByPaneKey,
pendingReconnectWorktreeIds,
pendingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId,
everActivatedWorktreeIds: nextEverActivated,
// Why: seed hydrated active worktrees so the first activation has a Back target.
worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [],
worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1,
ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)),
terminalLayoutsByTabId: buildWorkspaceTerminalLayoutPlan({
ownershipTransfersByTabId,
ownershipTransferTabIds,
releasedPtyIdsByTabId,
session,
tabById,
validTabIds
})
}
return options?.replaceWorkspaceKeys
? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options)
: hydrated
})
for (const [tabId, transfers] of ownershipTransfersByTabId) {
transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers)
}
}
}
}
@@ -0,0 +1,58 @@
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { releaseTerminalLayoutPtyIds } from '../slices/terminal-session-row-hydration'
import {
normalizeTerminalLayoutSnapshot,
resolvePtyBoundActiveLeafId
} from '@/components/terminal-pane/terminal-layout-leaf-ids'
import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership'
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch'
export function buildWorkspaceTerminalLayoutPlan({
ownershipTransfersByTabId,
ownershipTransferTabIds,
releasedPtyIdsByTabId,
session,
tabById,
validTabIds
}: {
ownershipTransfersByTabId: Map<string, TerminalLayoutPtyOwnershipTransfer[]>
ownershipTransferTabIds: ReadonlySet<string> | null
releasedPtyIdsByTabId: ReadonlyMap<string, ReadonlySet<string>>
session: WorkspaceSessionState
tabById: ReadonlyMap<string, TerminalTab>
validTabIds: ReadonlySet<string>
}): Record<string, TerminalLayoutSnapshot> {
return Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId)
.filter(([tabId]) => validTabIds.has(tabId))
.map(([tabId, persisted]) => {
const releasedPtyIds = releasedPtyIdsByTabId.get(tabId)
const layout = releasedPtyIds
? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds)
: persisted
const normalization = normalizeTerminalLayoutSnapshot(layout)
const normalized = normalization.snapshot
if (
normalization.changed &&
(!ownershipTransferTabIds || ownershipTransferTabIds.has(tabId))
) {
ownershipTransfersByTabId.set(
tabId,
resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized)
)
}
const tab = tabById.get(tabId)
const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized
const activeLeafId = sanitized.root
? resolvePtyBoundActiveLeafId({
root: sanitized.root,
activeLeafId: sanitized.activeLeafId,
ptyIdsByLeafId: sanitized.ptyIdsByLeafId
})
: sanitized.activeLeafId
return [tabId, { ...sanitized, activeLeafId }]
})
)
}
@@ -0,0 +1,91 @@
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id'
export function getPathDisplayName(path: string, fallback: string): string {
const normalized = path.trim().replace(/[\\/]+$/g, '')
const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim()
return basename || fallback
}
export function buildRuntimeSessionPlaceholders({
repos,
runtimeHostIdByWorkspaceSessionKey,
worktreesByRepo
}: {
repos: readonly Repo[]
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
worktreesByRepo: Record<string, Worktree[]>
}): {
repos: Repo[]
worktreesByRepo: Record<string, Worktree[]>
} {
let nextRepos = repos.slice()
let nextWorktreesByRepo = worktreesByRepo
for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) {
const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey]
if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
continue
}
const workspaceScope = parseWorkspaceKey(workspaceSessionKey)
if (workspaceScope?.type === 'folder') {
continue
}
const worktreeId =
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey
// Why: strip the synthetic `::workspace:<uuid>` suffix so path is the real folder — Git callers must not spawn against a nonexistent cwd.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!parsed) {
continue
}
const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId)
if (!existingRepo) {
// Why: remote catalogs load after hydration but host-split session writes need owner metadata; skip if the repo id already exists to avoid duplicates.
nextRepos = [
...nextRepos,
{
id: parsed.repoId,
path: parsed.worktreePath,
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: 0,
connectionId: null,
executionHostId: hostId
}
]
}
const current = nextWorktreesByRepo[parsed.repoId] ?? []
if (current.some((worktree) => worktree.id === worktreeId)) {
continue
}
const placeholder: Worktree = {
id: worktreeId,
repoId: parsed.repoId,
hostId,
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
path: parsed.worktreePath,
head: '',
branch: '',
isBare: false,
isMainWorktree: false
}
nextWorktreesByRepo =
nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo
nextWorktreesByRepo[parsed.repoId] = [...current, placeholder]
}
return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo }
}
@@ -0,0 +1,88 @@
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index'
export type WorkspaceTerminalReconnectPlan = {
pendingReconnectPtyIdByTabId: Record<string, string>
pendingReconnectTabByWorktree: Record<string, string[]>
pendingReconnectWorktreeIds: string[]
}
export function buildWorkspaceTerminalReconnectPlan({
reconnectPtyIdByRetainedTabId,
releasedPtyIdsByTabId,
repos,
session,
validTabIds,
validWorktreeIds,
worktreesByRepo
}: {
reconnectPtyIdByRetainedTabId: ReadonlyMap<string, string>
releasedPtyIdsByTabId: ReadonlyMap<string, ReadonlySet<string>>
repos: readonly Repo[]
session: WorkspaceSessionState
validTabIds: ReadonlySet<string>
validWorktreeIds: ReadonlySet<string>
worktreesByRepo: Record<string, Worktree[]>
}): WorkspaceTerminalReconnectPlan {
// The shutdown list is authoritative when present; PTY ids are wake hints, not activity state.
const shutdownIds =
session.activeWorktreeIdsOnShutdown ??
Object.entries(session.tabsByWorktree)
.filter(([, tabs]) => tabs.some((tab) => tab.ptyId))
.map(([worktreeId]) => worktreeId)
const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id))
const remoteSessionIds = session.remoteSessionIdsByTabId ?? {}
const pendingReconnectTabByWorktree: Record<string, string[]> = {}
for (const worktreeId of pendingReconnectWorktreeIds) {
const liveTabIds = (session.tabsByWorktree[worktreeId] ?? [])
.filter(
(tab) =>
(tab.ptyId || remoteSessionIds[tab.id] || reconnectPtyIdByRetainedTabId.has(tab.id)) &&
validTabIds.has(tab.id)
)
.map((tab) => tab.id)
if (liveTabIds.length > 0) {
pendingReconnectTabByWorktree[worktreeId] = liveTabIds
}
}
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
const worktreeById = buildWorktreeByIdIndex(worktreesByRepo)
const repoById = buildByIdIndex(repos)
for (const worktreeId of pendingReconnectWorktreeIds) {
const worktree = worktreeById.get(worktreeId)
const repo = worktree ? repoById.get(worktree.repoId) : null
// SSH sessions reconnect through their relay rather than the local daemon.
if (repo?.connectionId) {
continue
}
for (const tab of session.tabsByWorktree[worktreeId] ?? []) {
if (
tab.ptyId &&
validTabIds.has(tab.id) &&
!releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId)
) {
pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId
}
}
}
for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) {
if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) {
pendingReconnectPtyIdByTabId[tabId] = sessionId
}
}
// Retained split rows need an owned leaf PTY anchor until their pane remounts.
for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) {
if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) {
pendingReconnectPtyIdByTabId[tabId] = ptyId
}
}
return {
pendingReconnectPtyIdByTabId,
pendingReconnectTabByWorktree,
pendingReconnectWorktreeIds
}
}
@@ -0,0 +1,195 @@
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { isCurrentDirectSshAuthority } from './terminal-pty-identities'
export function createWorkspaceTerminalReconnectActions(
set: TerminalStoreSet,
get: TerminalStoreGet
): Pick<TerminalSlice, 'reconnectPersistedTerminals'> {
return {
reconnectPersistedTerminals: async (signal, options) => {
if (
signal?.aborted ||
(options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority))
) {
return
}
const {
pendingReconnectWorktreeIds,
pendingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId,
terminalLayoutsByTabId,
tabsByWorktree,
ptyIdsByTabId
} = get()
const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null
const ids = (pendingReconnectWorktreeIds ?? []).filter(
(id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id)
)
if (ids.length === 0) {
if (options) {
return
}
set({
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {}
})
return
}
// Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output.
let reconnectedTabsByWorktree: Record<string, TerminalTab[]> | null = null
let reconnectedPtyIdsByTabId: Record<string, string[]> | null = null
// Why indexed: the loop neither sets state nor awaits, so one index over the
// whole store snapshot serves every iteration.
const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo)
const repoById = buildByIdIndex(get().repos)
for (const worktreeId of ids) {
const tabs = tabsByWorktree[worktreeId] ?? []
const worktree = worktreeById.get(worktreeId)
const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null
// Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail.
const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null
const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null
const sshConnected = sshTargetId != null && sshState?.status === 'connected'
const supportsDeferredReattach = options
? sshConnected
: !repo?.connectionId || sshConnected
console.debug(
`[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}`
)
const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? []
const tabsToReconnect: TerminalTab[] =
targetTabIds.length > 0
? targetTabIds
.map((id) => tabs.find((t) => t.id === id))
.filter((t): t is TerminalTab => t != null)
: tabs.slice(0, 1)
if (tabsToReconnect.length === 0) {
continue
}
for (const tab of tabsToReconnect) {
const tabId = tab.id
const layout = terminalLayoutsByTabId[tabId]
const leafPtyMap = layout?.ptyIdsByLeafId ?? {}
const pendingPtyId = pendingReconnectPtyIdByTabId[tabId]
const tabLevelPtyId =
options &&
parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !==
options.directSshAuthority.targetId
? undefined
: pendingPtyId
const hasLeafMappings = Object.keys(leafPtyMap).length > 0
// Why: publish live PTY hints before mount; pty-connection reattaches later.
console.debug(
`[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}`
)
// Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts.
// A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising.
const allPtyIds = hasLeafMappings
? (Object.values(leafPtyMap).filter(Boolean) as string[])
: tabLevelPtyId
? [tabLevelPtyId]
: []
if (allPtyIds.length > 0) {
// Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them.
reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId }
reconnectedPtyIdsByTabId[tabId] = allPtyIds
}
if (tabLevelPtyId) {
reconnectedTabsByWorktree ??= { ...tabsByWorktree }
const nextTabs = reconnectedTabsByWorktree[worktreeId]
if (!nextTabs) {
continue
}
reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) =>
t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t
)
}
}
}
// Why: keep deferred SSH session IDs for post-cleanup reconnect.
const scopedTabIds = new Set(
[...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) =>
(tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)
)
)
const deferredSshSessionIdsByTabId: Record<string, string> = options
? Object.fromEntries(
Object.entries(get().deferredSshSessionIdsByTabId).filter(
([tabId]) => !scopedTabIds.has(tabId)
)
)
: {}
for (const worktreeId of ids) {
const worktree = worktreeById.get(worktreeId)
// Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map.
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = repoId ? (repoById.get(repoId) ?? null) : null
const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId
if (!connectionId) {
continue
}
// Why: a repo can outlive its SSH target when the target was removed out of
// band (a crash between removal and cleanup, or edited out of the config).
// Once the authoritative target list has loaded, don't re-defer sessions for
// a target it no longer lists — a stranded deferred id reads as liveness and
// the orphan sweep could never remove the dead tab. Defer while the list is
// still unknown so a normal cold-start reconnect isn't dropped (#9911).
if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) {
continue
}
const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected'
if (sshConnected) {
continue
}
const tabs = tabsByWorktree[worktreeId] ?? []
for (const tab of tabs) {
const sessionId = pendingReconnectPtyIdByTabId[tab.id]
if (
sessionId &&
(!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId)
) {
deferredSshSessionIdsByTabId[tab.id] = sessionId
}
}
}
if (
signal?.aborted ||
(options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority))
) {
return
}
const remainingReconnectWorktreeIds = options
? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id))
: []
const remainingReconnectTabByWorktree = options
? Object.fromEntries(
Object.entries(pendingReconnectTabByWorktree).filter(
([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey)
)
)
: {}
const remainingReconnectPtyIdByTabId = options
? Object.fromEntries(
Object.entries(pendingReconnectPtyIdByTabId).filter(
([tabId]) => !scopedTabIds.has(tabId)
)
)
: {}
set({
...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}),
...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}),
...(options ? {} : { workspaceSessionReady: true }),
pendingReconnectWorktreeIds: remainingReconnectWorktreeIds,
pendingReconnectTabByWorktree: remainingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId,
deferredSshSessionIdsByTabId
})
}
}
}
@@ -0,0 +1,101 @@
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import {
collectReleasedLeafIds,
hydrateWorkspaceTerminalRows
} from '../slices/terminal-session-row-hydration'
import { removeSleepingAgentSessionsForTab } from '../slices/terminal-tab-retirement'
import type { HydrateWorkspaceSessionOptions } from './terminal-contracts'
export type WorkspaceTerminalRowPlan = {
canonicalTabIdBySubsumedTabId: Map<string, string>
reconnectPtyIdByRetainedTabId: Map<string, string>
releasedPtyIdsByTabId: Map<string, Set<string>>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
tabsByWorktree: Record<string, TerminalTab[]>
validTabIds: Set<string>
}
export function buildWorkspaceTerminalRowPlan(
session: WorkspaceSessionState,
validWorktreeIds: ReadonlySet<string>,
options?: HydrateWorkspaceSessionOptions
): WorkspaceTerminalRowPlan {
// Remote wire rows are authoritative independently of locally persisted unified tabs.
const remoteSnapshotWorkspaceKeys = new Set(
options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : []
)
const rowHydrationByWorktree = Object.entries(session.tabsByWorktree)
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
.map(
([worktreeId, tabs]) =>
[
worktreeId,
hydrateWorkspaceTerminalRows(session, worktreeId, tabs, {
rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId)
})
] as const
)
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
rowHydrationByWorktree
.map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const)
.filter(
([worktreeId, tabs]) => tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0
)
)
const releasedPtyIdsByTabId = new Map<string, Set<string>>(
rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId])
)
const reconnectPtyIdByRetainedTabId = new Map<string, string>(
rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.reconnectPtyIdByRetainedTabId])
)
const canonicalTabIdBySubsumedTabId = new Map<string, string>(
rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.canonicalTabIdBySubsumedTabId])
)
const validTabIds = new Set(
Object.values(tabsByWorktree)
.flat()
.map((tab) => tab.id)
)
let sleepingAgentSessionsByPaneKey = Object.fromEntries(
Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) =>
validWorktreeIds.has(record.worktreeId)
)
)
// Dropped rows never reach retirement, so remove their otherwise-stranded sleeping records.
for (const [, hydration] of rowHydrationByWorktree) {
for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) {
sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab(
sleepingAgentSessionsByPaneKey,
tabId
)
}
}
// Released leaves transferred to canonical rows and must not retain a duplicate sleeping owner.
const releasedPaneKeys = new Set<string>(
[...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) =>
collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds)
.filter(isTerminalLeafId)
.map((leafId) => makePaneKey(tabId, leafId))
)
)
if (releasedPaneKeys.size > 0) {
sleepingAgentSessionsByPaneKey = Object.fromEntries(
Object.entries(sleepingAgentSessionsByPaneKey).filter(
([paneKey]) => !releasedPaneKeys.has(paneKey)
)
)
}
return {
canonicalTabIdBySubsumedTabId,
reconnectPtyIdByRetainedTabId,
releasedPtyIdsByTabId,
sleepingAgentSessionsByPaneKey,
tabsByWorktree,
validTabIds
}
}
@@ -0,0 +1,51 @@
import type { Repo } from '../../../../shared/repo-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
getRepoIdFromWorktreeId,
splitWorktreeIdForFilesystem
} from '../../../../shared/worktree/id'
export function addHydratedSshWorktreePlaceholders(
repos: readonly Repo[],
sourceWorktreesByRepo: Record<string, Worktree[]>,
tabsByWorktree: Record<string, TerminalTab[]>
): Record<string, Worktree[]> {
const sshRepoIds = new Set(repos.filter((repo) => repo.connectionId).map((repo) => repo.id))
const worktreesByRepo = { ...sourceWorktreesByRepo }
for (const worktreeId of Object.keys(tabsByWorktree)) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
if (!sshRepoIds.has(repoId)) {
continue
}
if ((worktreesByRepo[repoId] ?? []).some((worktree) => worktree.id === worktreeId)) {
continue
}
// Strip synthetic folder suffixes so the placeholder path remains a valid cwd.
const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? ''
const displayName = path.split(/[/\\]/).pop() || path
const placeholder: Worktree = {
id: worktreeId,
repoId,
displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
path,
head: '',
branch: '',
isBare: false,
isMainWorktree: false
}
worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder]
}
return worktreesByRepo
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RepoSlice } from './slices/repos'
import type { SparsePresetsSlice } from './slices/sparse-presets'
import type { WorktreeSlice } from './slices/worktrees'
import type { TerminalSlice } from './slices/terminals'
import type { TerminalSlice } from './terminals/terminal-state'
import type { TabsSlice } from './slices/tabs'
import type { UISlice } from './slices/ui'
import type { SettingsSlice } from './slices/settings'