fix(codex): execute accepted account-switch restarts for unmounted panes (#12146)
* fix(codex): execute accepted account-switch restarts for unmounted panes Accepting the Codex account-switch restart prompt queued every awaiting pane but only mounted TerminalPane instances executed the queue, so background-tab and parked panes stayed input-blocked on the old account with no prompt. - Add a detached store-level driver (codex-detached-pane-restart) that kill-and-respawns any queued pane no mounted transport claims, rebinding tab/layout state so a later mount reattaches to the replacement PTY. - Re-offer the prompt when a detached execution fails, and clear the notice when the pane is gone, so input is never silently blocked. - Sweep restored PTY ids at startup so stale panes in never-mounted tabs are re-offered after an app restart. - Carry launchAgent codex on the restart respawn so it waits for managed-auth readiness and records the pane's launch account. * fix(codex): fence detached restart ownership races * fix(codex): unblock detached restart handoffs * fix(codex): contain detached restart cleanup * fix(codex): detach restart progress from cleanup * fix(codex): avoid detached restart size wait * fix(terminal): scope Codex restart prompt to pane * fix(codex): contain detached restart sweep failures
This commit is contained in:
parent
a20165a43d
commit
bcf7d1df1b
|
|
@ -123,6 +123,8 @@ import {
|
|||
shouldPersistWorkspaceSession
|
||||
} from './lib/workspace-session'
|
||||
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
|
||||
import { sweepRestoredCodexPanesForStaleAccounts } from './lib/codex-stale-pane-sweep'
|
||||
import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler'
|
||||
import { buildActiveViewUnloadPatch } from './lib/active-view-persist'
|
||||
import {
|
||||
buildWorkspaceSessionHostSnapshots,
|
||||
|
|
@ -1090,6 +1092,9 @@ function App(): React.JSX.Element {
|
|||
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
// Why here: reconnect just published restored PTY ids; sweeping them now
|
||||
// re-offers stale Codex panes whose tabs never mount this session.
|
||||
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
|
||||
syncZoomCSSVar()
|
||||
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
|
||||
actions.setHydrationSucceeded(true)
|
||||
|
|
@ -1210,6 +1215,8 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => installCodexDetachedPaneRestartExecutor(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
|
||||
return useAppStore.subscribe((state, previousState) => {
|
||||
|
|
|
|||
|
|
@ -1,70 +1,11 @@
|
|||
import { useEffect, useId, useMemo, useRef } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '../store'
|
||||
import { selectCodexRestartInputs } from './codex-restart-chip-inputs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus'
|
||||
import { buildCodexRestartNoticeKey } from './codex-restart-notice-key'
|
||||
import { awaitsCodexRestartAnswer } from './codex-restart-notice-state'
|
||||
import type { CodexRestartNotice } from '../store/slices/terminals'
|
||||
|
||||
const EMPTY_TABS: { id: string }[] = []
|
||||
|
||||
export function collectStalePtyIdsForTabs({
|
||||
tabs,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId
|
||||
}: {
|
||||
tabs: { id: string }[]
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice | undefined>
|
||||
}): string[] {
|
||||
// Why: an already-requested restart runs when its pane next mounts. Keeping it
|
||||
// out of the prompt is what stops the panel from sticking on a worktree whose
|
||||
// stale pane is parked or deferred and cannot answer the request yet. A
|
||||
// dismissed notice is likewise answered — it only survives as launch-account
|
||||
// memory.
|
||||
return tabs.flatMap((tab) =>
|
||||
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) =>
|
||||
awaitsCodexRestartAnswer(codexRestartNoticeByPtyId[ptyId])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function collectStaleWorktreePtyIds({
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId,
|
||||
worktreeId
|
||||
}: {
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice | undefined>
|
||||
worktreeId: string
|
||||
}): string[] {
|
||||
return collectStalePtyIdsForTabs({
|
||||
tabs: tabsByWorktree[worktreeId] ?? EMPTY_TABS,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId
|
||||
})
|
||||
}
|
||||
|
||||
export function dismissStaleWorktreePtyIds(
|
||||
staleWorktreePtyIds: string[],
|
||||
dismissCodexRestartNotices: (ptyIds: string[]) => void,
|
||||
forgetLaunchAccounts: (ptyIds: string[]) => void
|
||||
): void {
|
||||
// Why: restart notices are stored per PTY, but the workspace host presents
|
||||
// one shared prompt. Dismissing all matching PTY notices keeps every pane in
|
||||
// that worktree consistent with the dismissal.
|
||||
dismissCodexRestartNotices(staleWorktreePtyIds)
|
||||
// Why: notices are renderer-only, so without dropping the on-disk launch
|
||||
// record the startup sweep re-raises this exact prompt — and re-blocks the
|
||||
// pane's input — after every app restart the user already answered.
|
||||
forgetLaunchAccounts(staleWorktreePtyIds)
|
||||
}
|
||||
|
||||
function isInsideHiddenTree(element: HTMLElement): boolean {
|
||||
return element.closest('[aria-hidden="true"], [hidden], [inert]') !== null
|
||||
|
|
@ -78,58 +19,39 @@ type RestartNotice = {
|
|||
|
||||
export default function CodexRestartChip({
|
||||
isVisible = true,
|
||||
worktreeId
|
||||
ptyId,
|
||||
shouldFocus = false
|
||||
}: {
|
||||
isVisible?: boolean
|
||||
worktreeId: string
|
||||
ptyId: string
|
||||
shouldFocus?: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
// Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId
|
||||
// on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on
|
||||
// pty teardown), so subscribe to them only while a restart notice actually
|
||||
// exists. Otherwise this per-worktree chip re-rendered on every pty event to
|
||||
// compute "no notice → render nothing". See codex-restart-chip-inputs.
|
||||
const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore(
|
||||
useShallow(selectCodexRestartInputs)
|
||||
)
|
||||
const staleWorktreePtyIds = useMemo(
|
||||
() =>
|
||||
collectStalePtyIdsForTabs({
|
||||
tabs,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId
|
||||
}),
|
||||
[codexRestartNoticeByPtyId, ptyIdsByTabId, tabs]
|
||||
)
|
||||
const restartNotice = staleWorktreePtyIds[0]
|
||||
? codexRestartNoticeByPtyId[staleWorktreePtyIds[0]]
|
||||
: undefined
|
||||
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
|
||||
const dismissCodexRestartNotices = useAppStore((s) => s.dismissCodexRestartNotices)
|
||||
|
||||
const noticeKey = restartNotice ? buildCodexRestartNoticeKey(restartNotice) : null
|
||||
|
||||
if (staleWorktreePtyIds.length === 0 || !restartNotice) {
|
||||
// Why: one O(1) selector per mounted pane stays idle when unrelated PTY maps
|
||||
// churn and prevents a worktree-wide scan for every split pane.
|
||||
const restartNotice = useAppStore((state) => state.codexRestartNoticeByPtyId[ptyId])
|
||||
if (!restartNotice || !awaitsCodexRestartAnswer(restartNotice)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleRestart = (): void => {
|
||||
queueCodexPaneRestarts(staleWorktreePtyIds)
|
||||
useAppStore.getState().queueCodexPaneRestarts([ptyId])
|
||||
}
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
dismissStaleWorktreePtyIds(staleWorktreePtyIds, dismissCodexRestartNotices, (ptyIds) => {
|
||||
void window.api.codexAccounts.forgetStalePanes({ ptyIds }).catch((err: unknown) => {
|
||||
console.warn('Failed to forget dismissed Codex pane accounts:', err)
|
||||
})
|
||||
useAppStore.getState().dismissCodexRestartNotices([ptyId])
|
||||
// Why: notices are renderer-only, so the persisted launch record must be
|
||||
// cleared for this pane or the startup sweep re-raises its answered prompt.
|
||||
void window.api.codexAccounts.forgetStalePanes({ ptyIds: [ptyId] }).catch((err: unknown) => {
|
||||
console.warn('Failed to forget dismissed Codex pane account:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<LoudRestartOverlay
|
||||
isVisible={isVisible}
|
||||
noticeKey={noticeKey}
|
||||
noticeKey={`${ptyId}:${buildCodexRestartNoticeKey(restartNotice)}`}
|
||||
restartNotice={restartNotice}
|
||||
shouldFocus={shouldFocus}
|
||||
onDismiss={handleDismiss}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
|
@ -140,12 +62,14 @@ function LoudRestartOverlay({
|
|||
isVisible,
|
||||
noticeKey,
|
||||
restartNotice,
|
||||
shouldFocus,
|
||||
onDismiss,
|
||||
onRestart
|
||||
}: {
|
||||
isVisible: boolean
|
||||
noticeKey: string | null
|
||||
restartNotice: RestartNotice
|
||||
shouldFocus: boolean
|
||||
onDismiss: () => void
|
||||
onRestart: () => void
|
||||
}): React.JSX.Element {
|
||||
|
|
@ -156,15 +80,10 @@ function LoudRestartOverlay({
|
|||
// Why: move focus to the card only when the user isn't typing elsewhere;
|
||||
// unconditional autoFocus would steal keys from an active composer.
|
||||
//
|
||||
// The target is the dialog itself, never Restart. This card is mounted per
|
||||
// WORKTREE (a sibling of the split layout), so its focus scope is every
|
||||
// terminal in the worktree — a notice for one pane lands while the user may
|
||||
// be typing in a different, perfectly healthy pane. With Restart focused, the
|
||||
// next Space/Enter of their prose queued a restart of every stale pane here
|
||||
// and destroyed those sessions. Focus must not land on a destructive action
|
||||
// the user never aimed at. See #10863.
|
||||
// The target is the dialog itself, never Restart, so the next Space/Enter
|
||||
// cannot destroy a session the user did not explicitly choose. See #10863.
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
if (!isVisible || !shouldFocus) {
|
||||
return
|
||||
}
|
||||
const root = rootRef.current
|
||||
|
|
@ -175,7 +94,7 @@ function LoudRestartOverlay({
|
|||
if (shouldFocusMobileDriverAction(document.activeElement, document.body, paneScope)) {
|
||||
root.focus()
|
||||
}
|
||||
}, [isVisible, noticeKey])
|
||||
}, [isVisible, noticeKey, shouldFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ import {
|
|||
import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
import {
|
||||
findActivityTerminalPortal,
|
||||
useActivityTerminalPortals,
|
||||
|
|
@ -2400,7 +2399,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip isVisible={isVisible} worktreeId={workspace.id} />
|
||||
{(tabsByWorktree[workspace.id] ?? [])
|
||||
.filter((tab) =>
|
||||
shouldMountBackgroundWorktreeTab(
|
||||
|
|
@ -2656,7 +2654,6 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
|||
inert={!isVisible}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip isVisible={isVisible} worktreeId={worktreeId} />
|
||||
<TabGroupSplitLayout
|
||||
layout={layout}
|
||||
worktreeId={worktreeId}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import {
|
||||
EMPTY_CODEX_RESTART_INPUTS,
|
||||
selectCodexRestartInputs,
|
||||
type CodexRestartInputsState
|
||||
} from './codex-restart-chip-inputs'
|
||||
|
||||
describe('selectCodexRestartInputs', () => {
|
||||
it('returns the frozen empty bundle while no restart notice exists', () => {
|
||||
const state: CodexRestartInputsState = {
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {}
|
||||
}
|
||||
expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
|
||||
// Churning EITHER map while no notice exists must NOT change the selected
|
||||
// reference, so a useShallow subscription skips the re-render. This covers
|
||||
// the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty.
|
||||
const churnedPty: CodexRestartInputsState = {
|
||||
...state,
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] }
|
||||
}
|
||||
const churnedNotice: CodexRestartInputsState = {
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread
|
||||
}
|
||||
expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('re-idles once every surviving notice is answered', () => {
|
||||
// Why: answered notices linger as launch-account memory for the pty's whole
|
||||
// life, so existence alone would keep every chip subscribed to pty churn.
|
||||
const dismissed: CodexRestartInputsState = {
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', dismissed: true }
|
||||
}
|
||||
}
|
||||
expect(selectCodexRestartInputs(dismissed)).toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
|
||||
const alsoUnanswered: CodexRestartInputsState = {
|
||||
...dismissed,
|
||||
codexRestartNoticeByPtyId: {
|
||||
...dismissed.codexRestartNoticeByPtyId,
|
||||
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
}
|
||||
expect(selectCodexRestartInputs(alsoUnanswered)).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
})
|
||||
|
||||
it('exposes both live maps the instant a restart notice exists', () => {
|
||||
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
const codexRestartNoticeByPtyId = {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId }
|
||||
const selected = selectCodexRestartInputs(state)
|
||||
// Live references pass straight through so the stale-pty memo + notice lookup derive fully.
|
||||
expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId)
|
||||
expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId)
|
||||
expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
|
||||
})
|
||||
|
||||
it('shallow-changes only when a live map reference changes while a notice exists', () => {
|
||||
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
const s1: CodexRestartInputsState = {
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
}
|
||||
const r1 = selectCodexRestartInputs(s1)
|
||||
expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true)
|
||||
|
||||
const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } }
|
||||
expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { awaitsCodexRestartAnswer } from './codex-restart-notice-state'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
export type CodexRestartInputsState = Pick<AppState, 'ptyIdsByTabId' | 'codexRestartNoticeByPtyId'>
|
||||
|
||||
export type CodexRestartInputs = CodexRestartInputsState
|
||||
|
||||
// Why: shared frozen bundle returned while no Codex restart notice exists — the
|
||||
// overwhelmingly common case. CodexRestartChip is mounted once per worktree
|
||||
// Terminal (visible AND hidden-measurable) and once per split group, and reads
|
||||
// these two maps only to find panes carrying a restart notice, which appear only
|
||||
// on a Codex account switch. BOTH churn on unrelated pty lifecycle:
|
||||
// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and
|
||||
// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the
|
||||
// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either
|
||||
// re-rendered every mounted chip on that churn. Gate both behind notice-existence
|
||||
// so idle chips keep the same reference and stop reacting; behavior is identical
|
||||
// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be
|
||||
// mutated.
|
||||
export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({
|
||||
ptyIdsByTabId: {},
|
||||
codexRestartNoticeByPtyId: {}
|
||||
})
|
||||
|
||||
/**
|
||||
* Expose the pty and restart-notice maps to the chip only while at least one
|
||||
* Codex restart notice is live; otherwise return a stable frozen bundle so a
|
||||
* `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn.
|
||||
*/
|
||||
export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs {
|
||||
// Why: answered notices linger as launch-account memory for the life of the
|
||||
// pty, so gating on mere existence would leave every chip permanently
|
||||
// subscribed to that churn after a single dismissal.
|
||||
if (!Object.values(s.codexRestartNoticeByPtyId).some(awaitsCodexRestartAnswer)) {
|
||||
return EMPTY_CODEX_RESTART_INPUTS
|
||||
}
|
||||
return {
|
||||
ptyIdsByTabId: s.ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId
|
||||
}
|
||||
}
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../store'
|
||||
import CodexRestartChip, {
|
||||
collectStalePtyIdsForTabs,
|
||||
collectStaleWorktreePtyIds,
|
||||
dismissStaleWorktreePtyIds
|
||||
} from './CodexRestartChip'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('CodexRestartChip helpers', () => {
|
||||
it('collects all stale PTY ids for tabs in a worktree', () => {
|
||||
expect(
|
||||
collectStaleWorktreePtyIds({
|
||||
tabsByWorktree: {
|
||||
wt1: [{ id: 'tab-1' }, { id: 'tab-2' }],
|
||||
wt2: [{ id: 'tab-3' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1', 'pty-2'],
|
||||
'tab-2': ['pty-3'],
|
||||
'tab-3': ['pty-4']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
|
||||
'pty-3': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
|
||||
'pty-4': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
},
|
||||
worktreeId: 'wt1'
|
||||
})
|
||||
).toEqual(['pty-1', 'pty-3'])
|
||||
})
|
||||
|
||||
it('returns an empty list when a worktree has no stale PTYs', () => {
|
||||
expect(
|
||||
collectStaleWorktreePtyIds({
|
||||
tabsByWorktree: {
|
||||
wt1: [{ id: 'tab-1' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {},
|
||||
worktreeId: 'wt1'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('collects from one worktree tab slice without scanning the whole tab map', () => {
|
||||
expect(
|
||||
collectStalePtyIdsForTabs({
|
||||
tabs: [{ id: 'tab-1' }],
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': ['pty-2']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
|
||||
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
})
|
||||
).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
it('drops PTYs whose restart is already requested', () => {
|
||||
expect(
|
||||
collectStalePtyIdsForTabs({
|
||||
tabs: [{ id: 'tab-1' }],
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1', 'pty-2']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', restartRequested: true },
|
||||
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
})
|
||||
).toEqual(['pty-2'])
|
||||
})
|
||||
|
||||
it('dismisses every stale PTY notice in the worktree prompt', () => {
|
||||
const dismissCodexRestartNotices = vi.fn()
|
||||
const forgetLaunchAccounts = vi.fn()
|
||||
|
||||
dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], dismissCodexRestartNotices, forgetLaunchAccounts)
|
||||
|
||||
expect(dismissCodexRestartNotices).toHaveBeenCalledWith(['pty-1', 'pty-3'])
|
||||
expect(forgetLaunchAccounts).toHaveBeenCalledWith(['pty-1', 'pty-3'])
|
||||
})
|
||||
|
||||
it('drops dismissed PTYs from the prompt while keeping their launch account', () => {
|
||||
expect(
|
||||
collectStalePtyIdsForTabs({
|
||||
tabs: [{ id: 'tab-1' }],
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1', 'pty-2']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', dismissed: true },
|
||||
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
}
|
||||
})
|
||||
).toEqual(['pty-2'])
|
||||
})
|
||||
|
||||
it('renders only account-resolution actions without an external-store update loop', async () => {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': {
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Codex is still signed in as old@example.com')
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim())
|
||||
).toEqual(['Keep old account', 'Restart'])
|
||||
})
|
||||
|
||||
it('uses configuration wording for a home-route restart', async () => {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': {
|
||||
previousAccountLabel: 'System default',
|
||||
nextAccountLabel: 'System default',
|
||||
previousAccountId: null,
|
||||
nextAccountId: null,
|
||||
homeRouteChanged: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Codex setup changed')
|
||||
expect(container.textContent).toContain('This Codex session is using an outdated configuration')
|
||||
expect(container.textContent).toContain(
|
||||
'Restart this session to load your current Codex configuration.'
|
||||
)
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim())
|
||||
).toEqual(['Keep current session', 'Restart'])
|
||||
})
|
||||
|
||||
it('forgets the launch record when the user keeps the old account', async () => {
|
||||
const forgetStalePanes = vi.fn(() => Promise.resolve())
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { codexAccounts: { forgetStalePanes } }
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': {
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
}
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
const dismissButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Keep old account'
|
||||
)
|
||||
await act(async () => {
|
||||
dismissButton?.click()
|
||||
})
|
||||
|
||||
// Why: without this the startup sweep re-raises the prompt the user just answered.
|
||||
expect(forgetStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] })
|
||||
expect(container.textContent).toBe('')
|
||||
// Why: the record survives as the pane's launch-account memory, but marked
|
||||
// answered so it neither prompts again nor blocks the pane's keyboard.
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com',
|
||||
dismissed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('stays closed when the user re-selects the account the pane launched under', async () => {
|
||||
const forgetStalePanes = vi.fn(() => Promise.resolve())
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { codexAccounts: { forgetStalePanes } }
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
})
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
])
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
const dismissButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Keep old account'
|
||||
)
|
||||
await act(async () => {
|
||||
dismissButton?.click()
|
||||
})
|
||||
|
||||
// Re-select the pane's original account: it never left it, so there is
|
||||
// nothing to restart and nothing to prompt about.
|
||||
await act(async () => {
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
previousAccountLabel: 'new@example.com',
|
||||
nextAccountLabel: 'old@example.com'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reopens the prompt when a dismissed pane is out of date against a third account', async () => {
|
||||
const forgetStalePanes = vi.fn(() => Promise.resolve())
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { codexAccounts: { forgetStalePanes } }
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
})
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
])
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
const dismissButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Keep old account'
|
||||
)
|
||||
await act(async () => {
|
||||
dismissButton?.click()
|
||||
})
|
||||
expect(container.textContent).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
previousAccountLabel: 'new@example.com',
|
||||
nextAccountLabel: 'third@example.com'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
// Why: the dismissal answered "keep old instead of new", not "never prompt
|
||||
// again"; the pane really is out of date against third@example.com.
|
||||
expect(container.textContent).toContain('Codex is still signed in as old@example.com')
|
||||
expect(container.textContent).toContain('Restart this session to use third@example.com')
|
||||
})
|
||||
|
||||
it('closes the prompt after Restart even when no mounted pane can run it yet', async () => {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': {
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
}
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
const restartButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Restart'
|
||||
)
|
||||
await act(async () => {
|
||||
restartButton?.click()
|
||||
})
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
// Why: the pane still has to restart; only the prompt is answered.
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'pty-1': true })
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']?.restartRequested).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodexRestartChip focus target', () => {
|
||||
function renderWithFocusedSiblingPane(): HTMLTextAreaElement {
|
||||
// Mirrors Terminal.tsx, which mounts the chip as a SIBLING of the split
|
||||
// layout — so the chip's parentElement is the whole worktree surface and
|
||||
// its focus scope covers every pane in it, not just the stale one.
|
||||
const splitLayout = document.createElement('div')
|
||||
const healthyPaneInput = document.createElement('textarea')
|
||||
healthyPaneInput.className = 'xterm-helper-textarea'
|
||||
splitLayout.appendChild(healthyPaneInput)
|
||||
container.appendChild(splitLayout)
|
||||
healthyPaneInput.focus()
|
||||
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: null
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
})
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
])
|
||||
return healthyPaneInput
|
||||
}
|
||||
|
||||
it('never parks focus on Restart, so a stray keystroke cannot kill the session', async () => {
|
||||
renderWithFocusedSiblingPane()
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
// Regression (#10863): focus used to land on Restart. The card is worktree
|
||||
// scoped, so it fires while the user is typing in a DIFFERENT, healthy pane
|
||||
// — and the next Space/Enter of their prose restarted every stale pane here.
|
||||
const restartButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Restart'
|
||||
)
|
||||
expect(restartButton).toBeDefined()
|
||||
expect(document.activeElement).not.toBe(restartButton)
|
||||
expect((document.activeElement as HTMLElement | null)?.getAttribute('role')).toBe('dialog')
|
||||
|
||||
// The keystroke that used to destroy the session now does nothing.
|
||||
await act(async () => {
|
||||
;(document.activeElement as HTMLElement | null)?.click()
|
||||
})
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
|
||||
})
|
||||
|
||||
it('still moves focus off the terminal so the card is reachable by keyboard', async () => {
|
||||
const healthyPaneInput = renderWithFocusedSiblingPane()
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
|
||||
})
|
||||
|
||||
// Why: the fix must not turn into "never take focus" — assistive tech and
|
||||
// keyboard users still need to land on the dialog and Tab to its actions.
|
||||
expect(document.activeElement).not.toBe(healthyPaneInput)
|
||||
expect(document.activeElement).toBe(container.querySelector('[role="dialog"]'))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, Profiler } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../store'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const PTY_ONE = 'worktree-1@@pty-1'
|
||||
const PTY_TWO = 'worktree-1@@pty-2'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let forgetStalePanes: ReturnType<typeof vi.fn>
|
||||
let root: Root
|
||||
|
||||
function notice(previousAccountLabel: string, nextAccountLabel: string) {
|
||||
return { previousAccountLabel, nextAccountLabel }
|
||||
}
|
||||
|
||||
function button(scope: ParentNode, label: string): HTMLButtonElement {
|
||||
const match = Array.from(scope.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`missing ${label} button`)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
forgetStalePanes = vi.fn(() => Promise.resolve())
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { codexAccounts: { forgetStalePanes } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('CodexRestartChip pane ownership', () => {
|
||||
it('renders the notice owned by its exact PTY', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
|
||||
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CodexRestartChip ptyId={PTY_TWO} />)
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Codex is still signed in as old-two@example.com')
|
||||
expect(container.textContent).toContain('Restart this session to use new-two@example.com')
|
||||
expect(container.textContent).not.toContain('old-one@example.com')
|
||||
})
|
||||
|
||||
it('uses configuration wording for a home-route restart', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: {
|
||||
previousAccountLabel: 'System default',
|
||||
nextAccountLabel: 'System default',
|
||||
previousAccountId: null,
|
||||
nextAccountId: null,
|
||||
homeRouteChanged: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Codex setup changed')
|
||||
expect(container.textContent).toContain('This Codex session is using an outdated configuration')
|
||||
expect(container.textContent).toContain(
|
||||
'Restart this session to load your current Codex configuration.'
|
||||
)
|
||||
})
|
||||
|
||||
it('restarts only the pane whose action was clicked', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
|
||||
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
<section data-pane="one">
|
||||
<CodexRestartChip ptyId={PTY_ONE} />
|
||||
</section>
|
||||
<section data-pane="two">
|
||||
<CodexRestartChip ptyId={PTY_TWO} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
})
|
||||
const firstPane = container.querySelector('[data-pane="one"]')!
|
||||
const secondPane = container.querySelector('[data-pane="two"]')!
|
||||
|
||||
await act(async () => {
|
||||
button(firstPane, 'Restart').click()
|
||||
})
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.pendingCodexPaneRestartIds).toEqual({ [PTY_ONE]: true })
|
||||
expect(state.codexRestartNoticeByPtyId[PTY_ONE]?.restartRequested).toBe(true)
|
||||
expect(state.codexRestartNoticeByPtyId[PTY_TWO]).toEqual(
|
||||
notice('old-two@example.com', 'new-two@example.com')
|
||||
)
|
||||
expect(firstPane.textContent).toBe('')
|
||||
expect(secondPane.textContent).toContain('old-two@example.com')
|
||||
})
|
||||
|
||||
it('dismisses and forgets only the pane whose action was clicked', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
|
||||
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
<section data-pane="one">
|
||||
<CodexRestartChip ptyId={PTY_ONE} />
|
||||
</section>
|
||||
<section data-pane="two">
|
||||
<CodexRestartChip ptyId={PTY_TWO} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
})
|
||||
const firstPane = container.querySelector('[data-pane="one"]')!
|
||||
const secondPane = container.querySelector('[data-pane="two"]')!
|
||||
|
||||
await act(async () => {
|
||||
button(firstPane, 'Keep old account').click()
|
||||
})
|
||||
|
||||
expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] })
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]?.dismissed).toBe(true)
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_TWO]?.dismissed).toBeUndefined()
|
||||
expect(firstPane.textContent).toBe('')
|
||||
expect(secondPane.textContent).toContain('old-two@example.com')
|
||||
})
|
||||
|
||||
it('follows a pane replacement id without acting on the retired PTY', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: notice('retired@example.com', 'target@example.com')
|
||||
}
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_TWO]: notice('replacement@example.com', 'target@example.com')
|
||||
}
|
||||
})
|
||||
root.render(<CodexRestartChip ptyId={PTY_TWO} />)
|
||||
})
|
||||
await act(async () => {
|
||||
button(container, 'Restart').click()
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [PTY_TWO]: true })
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds[PTY_ONE]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays hidden after this pane answers while a sibling remains unanswered', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: { ...notice('a', 'b'), restartRequested: true },
|
||||
[PTY_TWO]: notice('c', 'd')
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
|
||||
})
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('does not re-render for unrelated PTY and notice churn', async () => {
|
||||
const onRender = vi.fn()
|
||||
const ownedNotice = notice('a', 'b')
|
||||
useAppStore.setState({ codexRestartNoticeByPtyId: { [PTY_ONE]: ownedNotice } })
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Profiler id="chip" onRender={onRender}>
|
||||
<CodexRestartChip ptyId={PTY_ONE} />
|
||||
</Profiler>
|
||||
)
|
||||
})
|
||||
const commits = onRender.mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { unrelated: [PTY_TWO] },
|
||||
codexRestartNoticeByPtyId: {
|
||||
[PTY_ONE]: ownedNotice,
|
||||
[PTY_TWO]: notice('c', 'd')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(onRender).toHaveBeenCalledTimes(commits)
|
||||
})
|
||||
|
||||
it('does not mutate restart state during a StrictMode replay', async () => {
|
||||
useAppStore.setState({
|
||||
codexRestartNoticeByPtyId: { [PTY_ONE]: notice('old@example.com', 'new@example.com') }
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<CodexRestartChip ptyId={PTY_ONE} shouldFocus />
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]).toEqual(
|
||||
notice('old@example.com', 'new@example.com')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodexRestartChip pane focus', () => {
|
||||
async function renderPane(shouldFocus: boolean, isVisible = true): Promise<HTMLTextAreaElement> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<div data-pane="stale">
|
||||
<textarea className="xterm-helper-textarea" />
|
||||
<CodexRestartChip isVisible={isVisible} ptyId={PTY_ONE} shouldFocus={shouldFocus} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
const terminalInput = container.querySelector('textarea')!
|
||||
terminalInput.focus()
|
||||
await act(async () => {
|
||||
useAppStore.getState().markCodexRestartNotices([
|
||||
{
|
||||
ptyId: PTY_ONE,
|
||||
previousAccountLabel: 'old@example.com',
|
||||
nextAccountLabel: 'new@example.com'
|
||||
}
|
||||
])
|
||||
})
|
||||
return terminalInput
|
||||
}
|
||||
|
||||
it('focuses the dialog itself for the active stale pane', async () => {
|
||||
const terminalInput = await renderPane(true)
|
||||
|
||||
expect(document.activeElement).not.toBe(terminalInput)
|
||||
expect(document.activeElement).toBe(container.querySelector('[role="dialog"]'))
|
||||
expect(document.activeElement).not.toBe(button(container, 'Restart'))
|
||||
})
|
||||
|
||||
it('preserves focus when this stale pane is inactive', async () => {
|
||||
const terminalInput = await renderPane(false)
|
||||
|
||||
expect(document.activeElement).toBe(terminalInput)
|
||||
})
|
||||
|
||||
it('preserves focus while this pane is hidden', async () => {
|
||||
const terminalInput = await renderPane(true, false)
|
||||
|
||||
expect(document.activeElement).toBe(terminalInput)
|
||||
})
|
||||
})
|
||||
|
|
@ -64,6 +64,7 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy'
|
|||
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
|
||||
import { useTerminalFontZoom } from './useTerminalFontZoom'
|
||||
import CloseTerminalDialog, { type CloseTerminalDialogCopyKind } from './CloseTerminalDialog'
|
||||
import CodexRestartChip from '../CodexRestartChip'
|
||||
import { MobileDriverOverlay } from './MobileDriverOverlay'
|
||||
import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast'
|
||||
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
|
||||
|
|
@ -1709,6 +1710,9 @@ function TerminalPane(
|
|||
// transport, so a queued restart has no ptyId to match on the mount pass. The
|
||||
// reconnected PTY rewrites this map when it binds — `ptyIdsByTabId` does not,
|
||||
// because a restored id is already listed there before the pane ever mounts.
|
||||
// Panes with no mounted TerminalPane at all are executed by the detached
|
||||
// driver instead (codex-detached-pane-restart), which leaves anything a live
|
||||
// transport owns to this effect.
|
||||
const panePtyLayoutBindings = savedLayout.ptyIdsByLeafId
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
|
|
@ -2963,6 +2967,24 @@ function TerminalPane(
|
|||
})
|
||||
}}
|
||||
/>
|
||||
{managedPanes.map((pane) => {
|
||||
const ptyId =
|
||||
paneTransportsRef.current.get(pane.id)?.getPtyId() ??
|
||||
savedLayout.ptyIdsByLeafId?.[pane.leafId]
|
||||
if (!ptyId) {
|
||||
return null
|
||||
}
|
||||
return createPortal(
|
||||
<CodexRestartChip
|
||||
key={`codex-restart-${pane.id}-${ptyId}`}
|
||||
isVisible={isVisible}
|
||||
ptyId={ptyId}
|
||||
shouldFocus={isActive && isVisible && activePane?.id === pane.id}
|
||||
/>,
|
||||
pane.container,
|
||||
`codex-restart-${pane.id}`
|
||||
)
|
||||
})}
|
||||
{/* Why: the reconnect banner already owns SSH recovery UX; the z-50 error
|
||||
toast was painting over it (same bottom strip) with the raw ssh:connect failure. */}
|
||||
{terminalError && isActive && !showSshReconnectOverlay ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
resetDetachedCodexPaneRestartClaimsForTests,
|
||||
sweepUnclaimedCodexPaneRestarts
|
||||
} from './codex-detached-pane-restart'
|
||||
|
||||
let executorInstalled = false
|
||||
let executorGeneration = 0
|
||||
let sweepQueued = false
|
||||
let sweepRunning = false
|
||||
let sweepRequestedAfterRun = false
|
||||
|
||||
export function hasAddedPendingCodexPaneRestart(
|
||||
current: Record<string, true>,
|
||||
previous: Record<string, true>
|
||||
): boolean {
|
||||
if (current === previous) {
|
||||
return false
|
||||
}
|
||||
return Object.keys(current).some((ptyId) => !previous[ptyId])
|
||||
}
|
||||
|
||||
/** Installed once at app startup; returns the uninstaller (tests). */
|
||||
export function installCodexDetachedPaneRestartExecutor(): () => void {
|
||||
executorInstalled = true
|
||||
const generation = ++executorGeneration
|
||||
const unsubscribe = useAppStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
hasAddedPendingCodexPaneRestart(
|
||||
state.pendingCodexPaneRestartIds,
|
||||
previousState.pendingCodexPaneRestartIds
|
||||
)
|
||||
) {
|
||||
scheduleClaimSweep()
|
||||
}
|
||||
})
|
||||
scheduleClaimSweep()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
if (executorGeneration === generation) {
|
||||
executorInstalled = false
|
||||
executorGeneration += 1
|
||||
sweepQueued = false
|
||||
sweepRequestedAfterRun = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCodexDetachedPaneRestartExecutorForTests(): void {
|
||||
executorInstalled = false
|
||||
executorGeneration += 1
|
||||
sweepQueued = false
|
||||
sweepRunning = false
|
||||
sweepRequestedAfterRun = false
|
||||
resetDetachedCodexPaneRestartClaimsForTests()
|
||||
}
|
||||
|
||||
function scheduleClaimSweep(): void {
|
||||
if (!executorInstalled) {
|
||||
return
|
||||
}
|
||||
if (sweepRunning) {
|
||||
sweepRequestedAfterRun = true
|
||||
return
|
||||
}
|
||||
if (sweepQueued) {
|
||||
return
|
||||
}
|
||||
sweepQueued = true
|
||||
const generation = executorGeneration
|
||||
// Exact mounted-owner checks fence the claim; a microtask only exits the store write.
|
||||
queueMicrotask(() => {
|
||||
if (!executorInstalled || executorGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
sweepQueued = false
|
||||
if (sweepRunning) {
|
||||
sweepRequestedAfterRun = true
|
||||
return
|
||||
}
|
||||
sweepRunning = true
|
||||
void sweepUnclaimedCodexPaneRestarts()
|
||||
.catch((err) => {
|
||||
console.warn('[codex-restart] detached restart sweep failed:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
sweepRunning = false
|
||||
if (executorInstalled && sweepRequestedAfterRun) {
|
||||
sweepRequestedAfterRun = false
|
||||
scheduleClaimSweep()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { registerRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
|
||||
import { awaitsCodexRestartAnswer, blocksCodexPaneInput } from '../codex-restart-notice-state'
|
||||
import { ptyDataHandlers } from './pty-dispatcher'
|
||||
import { sweepUnclaimedCodexPaneRestarts } from './codex-detached-pane-restart'
|
||||
import {
|
||||
hasAddedPendingCodexPaneRestart,
|
||||
installCodexDetachedPaneRestartExecutor,
|
||||
resetCodexDetachedPaneRestartExecutorForTests
|
||||
} from './codex-detached-pane-restart-scheduler'
|
||||
|
||||
const ACCOUNT_A = 'a@example.com'
|
||||
const ACCOUNT_B = 'b@example.com'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OLD_PTY = 'wt1@@old'
|
||||
const NEW_PTY = 'wt1@@new'
|
||||
const UNLOCATED_PTY = 'wt1@@unlocated'
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
return { promise: new Promise<T>((done) => (resolve = done)), resolve }
|
||||
}
|
||||
|
||||
function seedQueuedRestart(
|
||||
opts: { leafId?: string | null; layoutRoot?: 'leaf' | 'none' } = {}
|
||||
): void {
|
||||
const leafId = opts.leafId === undefined ? LEAF_ID : opts.leafId
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
worktreesByRepo: {
|
||||
repo1: [{ id: 'wt1', path: '/Users/dev/code/orca' }]
|
||||
} as never,
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: OLD_PTY,
|
||||
worktreeId: 'wt1',
|
||||
title: 'codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex' as const
|
||||
}
|
||||
]
|
||||
} as never,
|
||||
ptyIdsByTabId: { 'tab-1': [OLD_PTY] },
|
||||
terminalLayoutsByTabId: leafId
|
||||
? {
|
||||
'tab-1': {
|
||||
root: opts.layoutRoot === 'none' ? null : { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: OLD_PTY }
|
||||
}
|
||||
}
|
||||
: {}
|
||||
})
|
||||
useAppStore
|
||||
.getState()
|
||||
.markCodexRestartNotices([
|
||||
{ ptyId: OLD_PTY, previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
|
||||
])
|
||||
useAppStore.getState().queueCodexPaneRestarts([OLD_PTY])
|
||||
}
|
||||
|
||||
describe('codex detached pane restart executor', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
resetCodexDetachedPaneRestartExecutorForTests()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
getSize: vi.fn().mockResolvedValue({ cols: 120, rows: 30 }),
|
||||
spawn: vi.fn().mockResolvedValue({ id: NEW_PTY }),
|
||||
kill: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCodexDetachedPaneRestartExecutorForTests()
|
||||
ptyDataHandlers.delete(OLD_PTY)
|
||||
vi.useRealTimers()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('kill-and-respawns an accepted restart no mounted transport claims', async () => {
|
||||
seedQueuedRestart()
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(window.api.pty.spawn).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/Users/dev/code/orca',
|
||||
command: 'codex',
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchAgent: 'codex',
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF_ID,
|
||||
initiallyHidden: true
|
||||
})
|
||||
)
|
||||
expect(window.api.pty.getSize).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]?.env).toEqual(
|
||||
expect.objectContaining({
|
||||
ORCA_PANE_KEY: `tab-1:${LEAF_ID}`,
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'wt1',
|
||||
ORCA_WORKSPACE_ID: 'wt1'
|
||||
})
|
||||
)
|
||||
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY])
|
||||
expect(state.tabsByWorktree.wt1?.[0]?.ptyId).toBe(NEW_PTY)
|
||||
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY })
|
||||
expect(state.pendingCodexPaneRestartIds).toEqual({})
|
||||
expect(state.suppressedPtyExitIds[OLD_PTY]).toBeUndefined()
|
||||
// The whole point: the restart completed, so nothing may block input anymore.
|
||||
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(false)
|
||||
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[NEW_PTY])).toBe(false)
|
||||
})
|
||||
|
||||
it('executes via the store subscription without a lifecycle timeout', async () => {
|
||||
const uninstall = installCodexDetachedPaneRestartExecutor()
|
||||
try {
|
||||
seedQueuedRestart()
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
|
||||
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY))
|
||||
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
|
||||
} finally {
|
||||
uninstall()
|
||||
}
|
||||
})
|
||||
|
||||
it('reference-gates unrelated store writes before scanning pending ids', () => {
|
||||
const unchanged = new Proxy<Record<string, true>>(
|
||||
{},
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error('pending ids scanned')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(hasAddedPendingCodexPaneRestart(unchanged, unchanged)).toBe(false)
|
||||
expect(hasAddedPendingCodexPaneRestart({ [OLD_PTY]: true }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('contains one claim failure so later queued panes still restart', async () => {
|
||||
useAppStore
|
||||
.getState()
|
||||
.markCodexRestartNotices([
|
||||
{ ptyId: UNLOCATED_PTY, previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
|
||||
])
|
||||
useAppStore.getState().queueCodexPaneRestarts([UNLOCATED_PTY])
|
||||
seedQueuedRestart()
|
||||
const consumePendingCodexPaneRestart = useAppStore.getState().consumePendingCodexPaneRestart
|
||||
useAppStore.setState({
|
||||
consumePendingCodexPaneRestart: (ptyId) => {
|
||||
if (ptyId === UNLOCATED_PTY) {
|
||||
throw new Error('corrupt restored claim')
|
||||
}
|
||||
return consumePendingCodexPaneRestart(ptyId)
|
||||
}
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const uninstall = installCodexDetachedPaneRestartExecutor()
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.pendingCodexPaneRestartIds).toEqual({})
|
||||
expect(awaitsCodexRestartAnswer(state.codexRestartNoticeByPtyId[UNLOCATED_PTY])).toBe(true)
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[codex-restart] detached pane restart failed:',
|
||||
expect.objectContaining({ message: 'corrupt restored claim' })
|
||||
)
|
||||
} finally {
|
||||
uninstall()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a PTY owned by a mounted transport to the pane effect', async () => {
|
||||
seedQueuedRestart()
|
||||
ptyDataHandlers.set(OLD_PTY, () => {})
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
|
||||
})
|
||||
|
||||
it('leaves foreign-machine PTYs queued for their mounted pane path', async () => {
|
||||
useAppStore.setState({ pendingCodexPaneRestartIds: { 'remote:term-1': true } })
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'remote:term-1': true })
|
||||
})
|
||||
|
||||
it('clears the notice when the queued pane no longer exists', async () => {
|
||||
useAppStore
|
||||
.getState()
|
||||
.markCodexRestartNotices([
|
||||
{ ptyId: 'wt1@@gone', previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
|
||||
])
|
||||
useAppStore.getState().queueCodexPaneRestarts(['wt1@@gone'])
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(state.pendingCodexPaneRestartIds).toEqual({})
|
||||
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId['wt1@@gone'])).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds a rootless single-pane layout so the mount replays this leaf', async () => {
|
||||
// Regression guard: replayTerminalLayout mints a fresh leaf when the root
|
||||
// doesn't name this one, silently orphaning the respawned PTY on reveal.
|
||||
seedQueuedRestart({ layoutRoot: 'none' })
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: NEW_PTY }
|
||||
})
|
||||
)
|
||||
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
|
||||
})
|
||||
|
||||
it('rebinds only the codex leaf of a split and keeps the sibling', async () => {
|
||||
const SIBLING_LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
seedQueuedRestart()
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': [OLD_PTY, 'wt1@@sibling'] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SIBLING_LEAF }
|
||||
},
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: OLD_PTY, [SIBLING_LEAF]: 'wt1@@sibling' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
const layout = useAppStore.getState().terminalLayoutsByTabId['tab-1']
|
||||
expect(layout?.root?.type).toBe('split')
|
||||
expect(layout?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF_ID]: NEW_PTY,
|
||||
[SIBLING_LEAF]: 'wt1@@sibling'
|
||||
})
|
||||
// Split-pane safety: only the codex pane's PTY dies.
|
||||
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
|
||||
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([NEW_PTY, 'wt1@@sibling'])
|
||||
})
|
||||
|
||||
it('leaves a tab with a mounted TerminalPane to its own restart effect', async () => {
|
||||
seedQueuedRestart()
|
||||
const unregister = registerRuntimeTerminalTab({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt1',
|
||||
getManager: () => null,
|
||||
getContainer: () => null,
|
||||
getPtyIdForPane: () => null
|
||||
})
|
||||
try {
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
it('reaps a detached spawn and requeues when a pane mounts during the spawn', async () => {
|
||||
seedQueuedRestart()
|
||||
const pendingSpawn = deferred<{ id: string }>()
|
||||
const pendingKill = deferred<void>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise)
|
||||
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
|
||||
|
||||
const restart = sweepUnclaimedCodexPaneRestarts()
|
||||
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
|
||||
const unregister = registerRuntimeTerminalTab({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt1',
|
||||
getManager: () => null,
|
||||
getContainer: () => null,
|
||||
getPtyIdForPane: () => OLD_PTY
|
||||
})
|
||||
try {
|
||||
pendingSpawn.resolve({ id: NEW_PTY })
|
||||
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY))
|
||||
|
||||
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([OLD_PTY])
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
|
||||
await restart
|
||||
pendingKill.resolve()
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a detached spawn after the tab generation and leaf owner change', async () => {
|
||||
seedQueuedRestart()
|
||||
const pendingSpawn = deferred<{ id: string }>()
|
||||
const pendingKill = deferred<void>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise)
|
||||
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
|
||||
|
||||
const restart = sweepUnclaimedCodexPaneRestarts()
|
||||
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
|
||||
const state = useAppStore.getState()
|
||||
const notice = state.codexRestartNoticeByPtyId[OLD_PTY]
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
wt1: [{ ...state.tabsByWorktree.wt1![0]!, generation: 1, ptyId: 'wt1@@successor' }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['wt1@@successor'] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
...state.terminalLayoutsByTabId['tab-1']!,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'wt1@@successor' }
|
||||
}
|
||||
},
|
||||
codexRestartNoticeByPtyId: { 'wt1@@successor': notice! }
|
||||
})
|
||||
|
||||
pendingSpawn.resolve({ id: NEW_PTY })
|
||||
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY))
|
||||
|
||||
const after = useAppStore.getState()
|
||||
expect(after.ptyIdsByTabId['tab-1']).toEqual(['wt1@@successor'])
|
||||
expect(after.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe('wt1@@successor')
|
||||
expect(awaitsCodexRestartAnswer(after.codexRestartNoticeByPtyId['wt1@@successor'])).toBe(true)
|
||||
await restart
|
||||
pendingKill.resolve()
|
||||
})
|
||||
|
||||
it('leaves a sleep-retained pending id alone so wake can migrate it', async () => {
|
||||
// Why: hibernation unbinds a pane's PTY but keeps its pending restart, and
|
||||
// wake moves that entry onto the respawned PTY. No notice means nothing is
|
||||
// blocked, so consuming here would silently lose the accepted restart.
|
||||
useAppStore.setState({ pendingCodexPaneRestartIds: { 'wt1@@sleeping': true } })
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'wt1@@sleeping': true })
|
||||
})
|
||||
|
||||
it('re-offers the prompt instead of leaving a silent block when the respawn fails', async () => {
|
||||
seedQueuedRestart()
|
||||
vi.mocked(window.api.pty.spawn).mockRejectedValue(new Error('managed auth unavailable'))
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual([OLD_PTY])
|
||||
expect(state.pendingCodexPaneRestartIds).toEqual({})
|
||||
// The question is back on screen; input stays blocked but never silently.
|
||||
expect(awaitsCodexRestartAnswer(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(true)
|
||||
})
|
||||
|
||||
it('kills now and defers the Codex respawn to mount when the layout leaf is unknown', async () => {
|
||||
seedQueuedRestart({ leafId: null })
|
||||
|
||||
await sweepUnclaimedCodexPaneRestarts()
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(window.api.pty.spawn).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual([])
|
||||
expect(state.pendingStartupByTabId['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'codex',
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
)
|
||||
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes a rootless replacement startup before waiting for old PTY teardown', async () => {
|
||||
seedQueuedRestart({ leafId: null })
|
||||
const pendingKill = deferred<void>()
|
||||
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
|
||||
|
||||
const restart = sweepUnclaimedCodexPaneRestarts()
|
||||
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY))
|
||||
|
||||
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([])
|
||||
expect(useAppStore.getState().pendingStartupByTabId['tab-1']).toMatchObject({
|
||||
command: 'codex',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
expect(useAppStore.getState().suppressedPtyExitIds[OLD_PTY]).toBeUndefined()
|
||||
await restart
|
||||
pendingKill.resolve()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Detached executor for accepted Codex account-switch restarts.
|
||||
*
|
||||
* Why: queued restarts used to execute only inside mounted TerminalPane
|
||||
* instances, so accepting the prompt stranded every unmounted pane — prompt
|
||||
* gone, keyboard blocked, Codex still running under the old account until the
|
||||
* tab was next revealed. This driver watches pendingCodexPaneRestartIds and
|
||||
* kill-and-respawns any pane no mounted transport claims, rebinding the store
|
||||
* so a later mount reattaches to the replacement PTY like a restored session.
|
||||
*/
|
||||
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types'
|
||||
import type { AppState } from '@/store'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
|
||||
import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
|
||||
import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
hasCachedWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher'
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry'
|
||||
|
||||
const inFlightPtyIds = new Set<string>()
|
||||
|
||||
export function resetDetachedCodexPaneRestartClaimsForTests(): void {
|
||||
inFlightPtyIds.clear()
|
||||
}
|
||||
|
||||
export async function sweepUnclaimedCodexPaneRestarts(): Promise<void> {
|
||||
for (const ptyId of Object.keys(useAppStore.getState().pendingCodexPaneRestartIds)) {
|
||||
await sweepUnclaimedCodexPaneRestart(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
async function sweepUnclaimedCodexPaneRestart(ptyId: string): Promise<void> {
|
||||
let located: LocatedCodexPane | null = null
|
||||
let claimed = false
|
||||
try {
|
||||
// Why: remote-runtime spawns need that machine's transport assembly, which
|
||||
// only the mounted pane path carries today; leave those queued for mount.
|
||||
if (isForeignMachineCodexPtyId(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a live primary handler means a mounted pane owns this PTY, and its
|
||||
// restart effect re-runs on both the queue write and the transport bind —
|
||||
// it is guaranteed to claim, and only it can reconnect the xterm in place.
|
||||
if (ptyDataHandlers.has(ptyId) || inFlightPtyIds.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
located = locateCodexPane(state, ptyId)
|
||||
if (!located) {
|
||||
// Why not consume: a sleep-retained pending id is unbound on purpose and
|
||||
// wake migrates it onto the respawned PTY — taking it here would lose
|
||||
// that restart. Only a notice still muting input forces a resolution.
|
||||
if (state.codexRestartNoticeByPtyId[ptyId]) {
|
||||
if (state.consumePendingCodexPaneRestart(ptyId)) {
|
||||
state.clearCodexRestartNotice(ptyId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why the registry check too: a revealed tab reads its layout into a ref at
|
||||
// mount, before its transports bind (and register a primary handler). A
|
||||
// takeover in that window would kill the PTY the pane is attaching to.
|
||||
if (hasRegisteredRuntimeTerminalTab(located.tab.id)) {
|
||||
return
|
||||
}
|
||||
if (!useAppStore.getState().consumePendingCodexPaneRestart(ptyId)) {
|
||||
return
|
||||
}
|
||||
inFlightPtyIds.add(ptyId)
|
||||
claimed = true
|
||||
await executeDetachedCodexPaneRestart(located, ptyId)
|
||||
} catch (err) {
|
||||
console.warn('[codex-restart] detached pane restart failed:', err)
|
||||
// Why: one malformed claim must not abort later panes or leave this one with
|
||||
// an answered prompt whose restart never executed.
|
||||
if (located) {
|
||||
reopenCurrentCodexRestartPrompt(located, ptyId)
|
||||
} else {
|
||||
useAppStore.getState().reopenCodexRestartPrompt(ptyId)
|
||||
}
|
||||
} finally {
|
||||
if (claimed) {
|
||||
inFlightPtyIds.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocatedCodexPane = {
|
||||
worktreeId: string
|
||||
tab: TerminalTab
|
||||
leafId: string | null
|
||||
generation: number
|
||||
}
|
||||
|
||||
function locateCodexPane(state: AppState, ptyId: string): LocatedCodexPane | null {
|
||||
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.ptyId !== ptyId && !(state.ptyIdsByTabId[tab.id] ?? []).includes(ptyId)) {
|
||||
continue
|
||||
}
|
||||
const leafId =
|
||||
Object.entries(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).find(
|
||||
([, boundPtyId]) => boundPtyId === ptyId
|
||||
)?.[0] ?? null
|
||||
// Why the format check: pre-UUID layouts carry numeric leaf ids, which the
|
||||
// pane-key env and main's binding flush both reject.
|
||||
return {
|
||||
worktreeId,
|
||||
tab,
|
||||
leafId: leafId !== null && isTerminalLeafId(leafId) ? leafId : null,
|
||||
generation: tab.generation ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getWorkspacePath(state: AppState, worktreeId: string): string | null {
|
||||
const parsed = parseWorkspaceKey(worktreeId)
|
||||
if (parsed?.type === 'folder') {
|
||||
return (
|
||||
(state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId)
|
||||
?.folderPath ?? null
|
||||
)
|
||||
}
|
||||
return getWorktreeMapFromState(state).get(worktreeId)?.path ?? null
|
||||
}
|
||||
|
||||
function buildPaneIdentityEnv(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string
|
||||
): Record<string, string> {
|
||||
const parsed = parseWorkspaceKey(worktreeId)
|
||||
const folderWorkspace =
|
||||
parsed?.type === 'folder'
|
||||
? state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId)
|
||||
: null
|
||||
return {
|
||||
ORCA_WORKSPACE_ID: worktreeId,
|
||||
...(folderWorkspace
|
||||
? {
|
||||
ORCA_PROJECT_GROUP_ID: folderWorkspace.projectGroupId,
|
||||
ORCA_WORKSPACE_ROOT: folderWorkspace.folderPath
|
||||
}
|
||||
: {}),
|
||||
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktreeId
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDetachedCodexPaneRestart(
|
||||
located: LocatedCodexPane,
|
||||
ptyId: string
|
||||
): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
if (!located.leafId) {
|
||||
// Why: without a usable layout leaf the replacement cannot be bound in
|
||||
// place, so kill now and let the tab's next mount run the Codex startup.
|
||||
if (!isLocatedCodexPaneCurrent(state, located, ptyId)) {
|
||||
reopenCurrentCodexRestartPrompt(located, ptyId)
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
store.suppressPtyExit(ptyId)
|
||||
store.clearTabPtyId(located.tab.id, ptyId)
|
||||
store.consumeSuppressedPtyExit(ptyId)
|
||||
store.queueTabStartupCommand(located.tab.id, { ...CODEX_ACCOUNT_RESTART_STARTUP })
|
||||
store.clearCodexRestartNotice(ptyId)
|
||||
killReplacedCodexPanePty(ptyId)
|
||||
return
|
||||
}
|
||||
const { worktreeId, tab, leafId } = located
|
||||
|
||||
const workspacePath = getWorkspacePath(state, worktreeId)
|
||||
const cwd = tab.startupCwd ?? workspacePath ?? undefined
|
||||
const capabilities = hasCachedWindowsTerminalCapabilities()
|
||||
? getCachedWindowsTerminalCapabilities()
|
||||
: null
|
||||
// Why: same runtime context the mounted spawn ships (pty-connection.ts), so a
|
||||
// WSL-defaulted project respawns into the same distro it launched from.
|
||||
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, undefined, {
|
||||
wslAvailable: capabilities?.wslAvailable,
|
||||
availableWslDistros: capabilities?.wslDistros ?? null
|
||||
})
|
||||
|
||||
const currentState = useAppStore.getState()
|
||||
if (!isLocatedCodexPaneCurrent(currentState, located, ptyId)) {
|
||||
reopenCurrentCodexRestartPrompt(located, ptyId)
|
||||
return
|
||||
}
|
||||
if (hasRegisteredRuntimeTerminalTab(tab.id) || ptyDataHandlers.has(ptyId)) {
|
||||
currentState.queueCodexPaneRestarts([ptyId])
|
||||
return
|
||||
}
|
||||
|
||||
// Hidden replacements converge on mount; provider sizing must not delay ownership transfer.
|
||||
const spawned = await window.api.pty.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...(cwd ? { cwd } : {}),
|
||||
cwdFallback: 'worktree',
|
||||
env: buildPaneIdentityEnv(state, worktreeId, tab.id, leafId),
|
||||
command: CODEX_ACCOUNT_RESTART_STARTUP.command,
|
||||
startupCommandDelivery: CODEX_ACCOUNT_RESTART_STARTUP.startupCommandDelivery,
|
||||
launchAgent: CODEX_ACCOUNT_RESTART_STARTUP.launchAgent,
|
||||
worktreeId,
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
...(tab.shellOverride ? { shellOverride: tab.shellOverride } : {}),
|
||||
...(projectRuntime ? { projectRuntime } : {}),
|
||||
initiallyHidden: true
|
||||
})
|
||||
|
||||
const store = useAppStore.getState()
|
||||
if (!isLocatedCodexPaneCurrent(store, located, ptyId)) {
|
||||
reopenCurrentCodexRestartPrompt(located, ptyId)
|
||||
reapUnboundCodexPty(spawned.id, 'stale detached spawn')
|
||||
return
|
||||
}
|
||||
if (hasRegisteredRuntimeTerminalTab(tab.id) || ptyDataHandlers.has(ptyId)) {
|
||||
store.queueCodexPaneRestarts([ptyId])
|
||||
reapUnboundCodexPty(spawned.id, 'mounted-owner handoff spawn')
|
||||
return
|
||||
}
|
||||
store.updateTabPtyId(tab.id, spawned.id, ptyId)
|
||||
if (!useAppStore.getState().ptyIdsByTabId[tab.id]?.includes(spawned.id)) {
|
||||
// Why: the tab was retired while the spawn was in flight; without a binding
|
||||
// the fresh PTY would idle in the daemon forever, so reap it and stand down.
|
||||
store.clearCodexRestartNotice(ptyId)
|
||||
reapUnboundCodexPty(spawned.id, 'retired-tab spawn')
|
||||
return
|
||||
}
|
||||
rebindCodexPaneLayoutLeaf(tab.id, leafId, spawned.id)
|
||||
// Why both ids: updateTabPtyId migrates the replaced pane's notice onto the
|
||||
// new PTY; the restart it recorded is now done, so the block must lift.
|
||||
store.clearCodexRestartNotice(spawned.id)
|
||||
store.clearCodexRestartNotice(ptyId)
|
||||
|
||||
killReplacedCodexPanePty(ptyId)
|
||||
}
|
||||
|
||||
function isLocatedCodexPaneCurrent(
|
||||
state: AppState,
|
||||
located: LocatedCodexPane,
|
||||
ptyId: string
|
||||
): boolean {
|
||||
const currentTab = state.tabsByWorktree[located.worktreeId]?.find(
|
||||
(candidate) => candidate.id === located.tab.id
|
||||
)
|
||||
if (
|
||||
!currentTab ||
|
||||
currentTab.worktreeId !== located.worktreeId ||
|
||||
(currentTab.generation ?? 0) !== located.generation ||
|
||||
!(state.ptyIdsByTabId[located.tab.id] ?? []).includes(ptyId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
located.leafId === null ||
|
||||
state.terminalLayoutsByTabId[located.tab.id]?.ptyIdsByLeafId?.[located.leafId] === ptyId
|
||||
)
|
||||
}
|
||||
|
||||
function reopenCurrentCodexRestartPrompt(located: LocatedCodexPane, replacedPtyId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
const currentTab = state.tabsByWorktree[located.worktreeId]?.find(
|
||||
(candidate) => candidate.id === located.tab.id
|
||||
)
|
||||
const currentPtyId = located.leafId
|
||||
? state.terminalLayoutsByTabId[located.tab.id]?.ptyIdsByLeafId?.[located.leafId]
|
||||
: currentTab?.ptyId
|
||||
for (const candidate of [currentPtyId, replacedPtyId]) {
|
||||
if (candidate && state.codexRestartNoticeByPtyId[candidate]?.restartRequested) {
|
||||
state.reopenCodexRestartPrompt(candidate)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function layoutRootContainsLeaf(
|
||||
node: TerminalPaneLayoutNode | null | undefined,
|
||||
leafId: string
|
||||
): boolean {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
return node.leafId === leafId
|
||||
}
|
||||
return layoutRootContainsLeaf(node.first, leafId) || layoutRootContainsLeaf(node.second, leafId)
|
||||
}
|
||||
|
||||
function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void {
|
||||
const store = useAppStore.getState()
|
||||
const layout = store.terminalLayoutsByTabId[tabId]
|
||||
const boundLeafIds = Object.keys(layout?.ptyIdsByLeafId ?? {})
|
||||
// Why: mount replays panes from the root — a root that doesn't name this leaf
|
||||
// mints a fresh one and silently orphans the replacement PTY. Rewriting is
|
||||
// only safe when this is the tab's sole bound pane; a split keeps its root.
|
||||
if (!layoutRootContainsLeaf(layout?.root, leafId) && boundLeafIds.every((id) => id === leafId)) {
|
||||
store.setTabLayout(
|
||||
tabId,
|
||||
singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null)
|
||||
)
|
||||
return
|
||||
}
|
||||
store.replaceTerminalLayoutPanePtyId(tabId, leafId, newPtyId)
|
||||
}
|
||||
|
||||
function reapUnboundCodexPty(ptyId: string, reason: string): void {
|
||||
try {
|
||||
void window.api.pty.kill(ptyId).catch((err) => {
|
||||
console.warn(`[codex-restart] failed to reap ${reason}:`, err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(`[codex-restart] failed to reap ${reason}:`, err)
|
||||
}
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
|
||||
function killReplacedCodexPanePty(ptyId: string): void {
|
||||
// Why the disposal: a parked tab's exit sidecar treats any exit as the pane
|
||||
// dying — it would collapse the just-rebound leaf or close the whole tab.
|
||||
disposeParkedTerminalWatchersForPtyIds([ptyId])
|
||||
for (const snapshot of unregisterPtyDataHandlers([ptyId])) {
|
||||
snapshot.commit()
|
||||
}
|
||||
reapUnboundCodexPty(ptyId, 'replaced Codex pane PTY')
|
||||
}
|
||||
|
|
@ -27,9 +27,14 @@ function setLaunchAgentOnFirstTab(launchAgent: TuiAgent): void {
|
|||
|
||||
describe('CODEX_ACCOUNT_RESTART_STARTUP', () => {
|
||||
it('waits for shell readiness before relaunching Codex after an account switch', () => {
|
||||
// Why launchAgent is load-bearing: pty:spawn runs the managed-auth
|
||||
// readiness gate and Codex launch prep only for launchAgent 'codex', so
|
||||
// dropping it would let a restart respawn race the account handoff and
|
||||
// record a launch account the pane does not actually read.
|
||||
expect(CODEX_ACCOUNT_RESTART_STARTUP).toEqual({
|
||||
command: 'codex',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
expect(shouldUseShellReadyStartupDelivery(CODEX_ACCOUNT_RESTART_STARTUP)).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ import type { TuiAgent } from '../../../shared/types'
|
|||
|
||||
// Why: prompt integrations such as Starship can outlast the daemon's 300ms
|
||||
// Codex fast-path timeout; account restarts must wait until the shell accepts input.
|
||||
// Why launchAgent: pty:spawn runs the managed-auth readiness gate and Codex
|
||||
// launch prep (project trust pre-mark) only for launchAgent 'codex', so without
|
||||
// it a restart respawn could race the account handoff and record a launch
|
||||
// account the pane does not actually read.
|
||||
export const CODEX_ACCOUNT_RESTART_STARTUP = {
|
||||
command: 'codex',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchAgent: 'codex'
|
||||
} as const
|
||||
|
||||
export type CodexPaneScanResult = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
resetCodexStalePaneSweepForTests
|
||||
resetCodexStalePaneSweepForTests,
|
||||
sweepRestoredCodexPanesForStaleAccounts
|
||||
} from './codex-stale-pane-sweep'
|
||||
|
||||
const ACCOUNT_A = 'account-a@example.com'
|
||||
|
|
@ -443,4 +444,26 @@ describe('notifyCodexPaneBoundForStaleSweep', () => {
|
|||
nextAccountId: 'account-b'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-raises the prompt for a restored pane whose tab never mounts', async () => {
|
||||
// Regression: the bind-driven sweep needed a pane mount to fire, so a stale
|
||||
// pane restored into a background tab stayed on the old account silently.
|
||||
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
|
||||
|
||||
sweepRestoredCodexPanesForStaleAccounts({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1', 'remote:env-1@@term-1'] }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({
|
||||
ptyIds: ['pty-1']
|
||||
})
|
||||
expect(inspectCallCountFor('remote:env-1@@term-1')).toBe(0)
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B,
|
||||
previousAccountId: 'account-a',
|
||||
nextAccountId: 'account-b'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,6 +47,25 @@ export function notifyCodexPaneBoundForStaleSweep(ptyId: string): void {
|
|||
armForEarliestDue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues every restored PTY for the stale-account check without waiting for a
|
||||
* pane to mount.
|
||||
*
|
||||
* Why: the bind-driven sweep above only fires when a pane's transport binds, so
|
||||
* a stale Codex pane restored into a never-revealed tab kept running under the
|
||||
* old account with no prompt — and an accepted-then-quit restart was never
|
||||
* re-offered. Foreign-machine ids are dropped by the notify guard.
|
||||
*/
|
||||
export function sweepRestoredCodexPanesForStaleAccounts(state: {
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
}): void {
|
||||
for (const ptyIds of Object.values(state.ptyIdsByTabId)) {
|
||||
for (const ptyId of ptyIds) {
|
||||
notifyCodexPaneBoundForStaleSweep(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCodexStalePaneSweepForTests(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer)
|
||||
|
|
|
|||
|
|
@ -222,4 +222,63 @@ describe('codex restart notices with colliding account labels', () => {
|
|||
nextAccountId: 'account-c'
|
||||
})
|
||||
})
|
||||
|
||||
it('puts a failed accepted restart back to the unanswered prompt', () => {
|
||||
switchAccount('pty-1', A, B)
|
||||
useAppStore.getState().queueCodexPaneRestarts(['pty-1'])
|
||||
|
||||
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
|
||||
|
||||
// Why: the executor could not run the restart, so the pane must show the
|
||||
// question again rather than stay muted behind an answered prompt.
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
|
||||
previousAccountLabel: A,
|
||||
nextAccountLabel: B
|
||||
})
|
||||
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
|
||||
})
|
||||
|
||||
it('leaves unanswered and dismissed notices alone on a reopen request', () => {
|
||||
switchAccount('pty-1', A, B)
|
||||
const unanswered = useAppStore.getState().codexRestartNoticeByPtyId
|
||||
|
||||
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(unanswered)
|
||||
|
||||
useAppStore.getState().dismissCodexRestartNotices(['pty-1'])
|
||||
const dismissed = useAppStore.getState().codexRestartNoticeByPtyId
|
||||
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(dismissed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceTerminalLayoutPanePtyId', () => {
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
it('rebinds one leaf and leaves split siblings alone', () => {
|
||||
const OTHER_LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: null,
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-old', [OTHER_LEAF]: 'pty-sibling' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-1', LEAF, 'pty-new')
|
||||
|
||||
expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF]: 'pty-new',
|
||||
[OTHER_LEAF]: 'pty-sibling'
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing for a tab with no layout', () => {
|
||||
const before = useAppStore.getState().terminalLayoutsByTabId
|
||||
useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-none', LEAF, 'pty-new')
|
||||
expect(useAppStore.getState().terminalLayoutsByTabId).toBe(before)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -748,6 +748,11 @@ export type TerminalSlice = {
|
|||
) => string[]
|
||||
clearCodexRestartNotice: (ptyId: string) => void
|
||||
dismissCodexRestartNotices: (ptyIds: string[]) => void
|
||||
/** Puts an accepted-but-unexecuted restart back to the unanswered prompt, so a
|
||||
* failed execution never leaves a pane input-blocked with nothing visible. */
|
||||
reopenCodexRestartPrompt: (ptyId: string) => void
|
||||
/** Rebinds one layout leaf to a replacement PTY without a mounted pane. */
|
||||
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
|
||||
|
|
@ -3586,6 +3591,40 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
})
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue