diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 531e6a5cda4..a7537343494 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -7,6 +7,7 @@ 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 }[] = [] @@ -22,12 +23,13 @@ export function collectStalePtyIdsForTabs({ }): 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. + // 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) => { - const notice = codexRestartNoticeByPtyId[ptyId] - return Boolean(notice) && !notice?.restartRequested - }) + (ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => + awaitsCodexRestartAnswer(codexRestartNoticeByPtyId[ptyId]) + ) ) } @@ -51,15 +53,13 @@ export function collectStaleWorktreePtyIds({ export function dismissStaleWorktreePtyIds( staleWorktreePtyIds: string[], - clearCodexRestartNotice: (ptyId: string) => void, + dismissCodexRestartNotices: (ptyIds: string[]) => void, forgetLaunchAccounts: (ptyIds: string[]) => void ): void { // Why: restart notices are stored per PTY, but the workspace host presents - // one shared prompt. Clearing all matching PTY notices keeps every pane in + // one shared prompt. Dismissing all matching PTY notices keeps every pane in // that worktree consistent with the dismissal. - for (const ptyId of staleWorktreePtyIds) { - clearCodexRestartNotice(ptyId) - } + 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. @@ -104,7 +104,7 @@ export default function CodexRestartChip({ ? codexRestartNoticeByPtyId[staleWorktreePtyIds[0]] : undefined const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts) - const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice) + const dismissCodexRestartNotices = useAppStore((s) => s.dismissCodexRestartNotices) const noticeKey = restartNotice ? buildCodexRestartNoticeKey(restartNotice) : null @@ -117,7 +117,7 @@ export default function CodexRestartChip({ } const handleDismiss = (): void => { - dismissStaleWorktreePtyIds(staleWorktreePtyIds, clearCodexRestartNotice, (ptyIds) => { + dismissStaleWorktreePtyIds(staleWorktreePtyIds, dismissCodexRestartNotices, (ptyIds) => { void window.api.codexAccounts.forgetStalePanes({ ptyIds }).catch((err: unknown) => { console.warn('Failed to forget dismissed Codex pane accounts:', err) }) diff --git a/src/renderer/src/components/codex-restart-chip-inputs.test.ts b/src/renderer/src/components/codex-restart-chip-inputs.test.ts index ad6412efd16..9d54d5c1889 100644 --- a/src/renderer/src/components/codex-restart-chip-inputs.test.ts +++ b/src/renderer/src/components/codex-restart-chip-inputs.test.ts @@ -32,9 +32,32 @@ describe('selectCodexRestartInputs', () => { ) }) + 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': {} as never } + 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. @@ -47,7 +70,9 @@ describe('selectCodexRestartInputs', () => { const ptyIdsByTabId = { 'tab-1': ['pty-1'] } const s1: CodexRestartInputsState = { ptyIdsByTabId, - codexRestartNoticeByPtyId: { 'pty-1': {} as never } + codexRestartNoticeByPtyId: { + 'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' } + } } const r1 = selectCodexRestartInputs(s1) expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true) diff --git a/src/renderer/src/components/codex-restart-chip-inputs.ts b/src/renderer/src/components/codex-restart-chip-inputs.ts index eec46f6a8e7..52adc9b9e26 100644 --- a/src/renderer/src/components/codex-restart-chip-inputs.ts +++ b/src/renderer/src/components/codex-restart-chip-inputs.ts @@ -1,3 +1,4 @@ +import { awaitsCodexRestartAnswer } from './codex-restart-notice-state' import type { AppState } from '@/store/types' export type CodexRestartInputsState = Pick @@ -27,7 +28,10 @@ export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({ * `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn. */ export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs { - if (Object.keys(s.codexRestartNoticeByPtyId).length === 0) { + // 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 { diff --git a/src/renderer/src/components/codex-restart-chip.test.ts b/src/renderer/src/components/codex-restart-chip.test.ts index 8d62bcccf47..5f3a8f5b14f 100644 --- a/src/renderer/src/components/codex-restart-chip.test.ts +++ b/src/renderer/src/components/codex-restart-chip.test.ts @@ -98,17 +98,30 @@ describe('CodexRestartChip helpers', () => { }) it('dismisses every stale PTY notice in the worktree prompt', () => { - const clearCodexRestartNotice = vi.fn() + const dismissCodexRestartNotices = vi.fn() const forgetLaunchAccounts = vi.fn() - dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], clearCodexRestartNotice, forgetLaunchAccounts) + dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], dismissCodexRestartNotices, forgetLaunchAccounts) - expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(1, 'pty-1') - expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(2, 'pty-3') - expect(clearCodexRestartNotice).toHaveBeenCalledTimes(2) + 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: { @@ -188,7 +201,131 @@ describe('CodexRestartChip helpers', () => { // Why: without this the startup sweep re-raises the prompt the user just answered. expect(forgetStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) - expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + 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 () => { diff --git a/src/renderer/src/components/codex-restart-notice-state.ts b/src/renderer/src/components/codex-restart-notice-state.ts new file mode 100644 index 00000000000..a3f0cd6962c --- /dev/null +++ b/src/renderer/src/components/codex-restart-notice-state.ts @@ -0,0 +1,18 @@ +type CodexRestartNoticeState = { restartRequested?: true; dismissed?: true } | null | undefined + +/** + * True while the pane must not accept keystrokes. + * + * Why the two answered states differ: a requested restart still leaves the pane + * running under the old account until it actually relaunches, so its input stays + * blocked. A dismissal is the user choosing to keep that account, so blocking + * their keyboard would punish them for answering. + */ +export function blocksCodexPaneInput(notice: CodexRestartNoticeState): boolean { + return Boolean(notice) && !notice?.dismissed +} + +/** True while the restart prompt still has an unanswered question for the user. */ +export function awaitsCodexRestartAnswer(notice: CodexRestartNoticeState): boolean { + return Boolean(notice) && !notice?.dismissed && !notice?.restartRequested +} diff --git a/src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts b/src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts index 5a72e7670c7..e0dc25bc197 100644 --- a/src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts +++ b/src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts @@ -78,6 +78,25 @@ describe('summarizeCodexRestartStatus', () => { }) }) + it('stops offering sessions the user chose to keep on the old account', () => { + expect( + summarizeCodexRestartStatus({ + tabsByWorktree: { wt1: [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] }, + codexRestartNoticeByPtyId: { + // Why: the dismissed record survives only as launch-account memory. + 'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', dismissed: true }, + 'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' } + } + }) + ).toEqual({ + stalePtyIds: ['pty-2'], + staleSessionCount: 1, + staleTabCount: 1, + staleWorktreeCount: 1 + }) + }) + it('hides the prompt once every stale session has a requested restart', () => { expect( summarizeCodexRestartStatus({ diff --git a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts index 6d3f0d7202e..8d7c44d458f 100644 --- a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts +++ b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts @@ -1,3 +1,4 @@ +import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state' import type { CodexRestartNotice } from '@/store/slices/terminals' type TabLookup = Record @@ -28,9 +29,10 @@ export function summarizeCodexRestartStatus({ codexRestartNoticeByPtyId }: CodexRestartStatusSummaryInput): CodexRestartStatusSummary { // Why: a requested restart is already scheduled for its pane's next mount, so - // re-offering it here would leave a prompt whose button does nothing. + // re-offering it here would leave a prompt whose button does nothing; a + // dismissed notice survives only as launch-account memory. const stalePtyIds = Object.entries(codexRestartNoticeByPtyId) - .filter(([, notice]) => notice && !notice.restartRequested) + .filter(([, notice]) => awaitsCodexRestartAnswer(notice)) .map(([ptyId]) => ptyId) if (stalePtyIds.length === 0) { return EMPTY_CODEX_RESTART_STATUS_SUMMARY diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 422c25307ca..999240478e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -176,7 +176,12 @@ type StoreState = { } | null codexRestartNoticeByPtyId: Record< string, - { previousAccountLabel: string; nextAccountLabel: string } + { + previousAccountLabel: string + nextAccountLabel: string + restartRequested?: true + dismissed?: true + } > deferredSshReconnectTargets: string[] deferredSshSessionIdsByTabId: Record @@ -4496,6 +4501,112 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) + it('restores input to a Codex pane whose restart notice the user dismissed', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + // Why: the record survives a dismissal as launch-account memory, so the + // input gate has to read the marker rather than the record's existence. + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + + it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + + it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + // Why: unlike a dismissal, a queued restart leaves the pane running under + // the old account until it actually relaunches. + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', restartRequested: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).not.toHaveBeenCalled() + }) + it('blocks input to stale Codex panes until they restart', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index df1e39e6ebb..05e571d77b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' import { resolvePaneTitleDecision } from './terminal-title-evidence' +import { blocksCodexPaneInput } from '../codex-restart-notice-state' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' @@ -826,12 +827,12 @@ function isCodexPaneStale(args: { if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) { return false } - if (args.panePtyId && codexRestartNoticeByPtyId[args.panePtyId]) { + if (args.panePtyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId])) { return true } const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId) - if (tab?.ptyId && codexRestartNoticeByPtyId[tab.ptyId]) { + if (tab?.ptyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[tab.ptyId])) { return true } diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts new file mode 100644 index 00000000000..bd5a60111ea --- /dev/null +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '../index' + +const A = 'a@example.com' +const B = 'b@example.com' +const C = 'c@example.com' + +function switchAccount(ptyId: string, from: string, to: string): void { + useAppStore + .getState() + .markCodexRestartNotices([{ ptyId, previousAccountLabel: from, nextAccountLabel: to }]) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('codex restart notice lifecycle', () => { + it('raises no notice when the pane returns to the account it launched under', () => { + switchAccount('pty-1', A, B) + switchAccount('pty-1', B, A) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + }) + + it('keeps the launch account after a dismissal so re-selecting it raises no notice', () => { + switchAccount('pty-1', A, B) + useAppStore.getState().dismissCodexRestartNotices(['pty-1']) + + switchAccount('pty-1', B, A) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + }) + + it('marks a dismissal instead of erasing the pane record', () => { + switchAccount('pty-1', A, B) + useAppStore.getState().dismissCodexRestartNotices(['pty-1']) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: A, + nextAccountLabel: B, + dismissed: true + }) + }) + + it('re-raises the prompt against the pane launch account when a third account is selected', () => { + switchAccount('pty-1', A, B) + useAppStore.getState().dismissCodexRestartNotices(['pty-1']) + + switchAccount('pty-1', B, C) + + // Why: over-suppressing here would silently strand the pane on A while the + // user works under C; the labels must name the launch account, not B. + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: A, + nextAccountLabel: C + }) + }) + + it('drops a queued restart when the user dismisses the same pane', () => { + switchAccount('pty-1', A, B) + useAppStore.getState().queueCodexPaneRestarts(['pty-1']) + + useAppStore.getState().dismissCodexRestartNotices(['pty-1']) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: A, + nextAccountLabel: B, + dismissed: true + }) + expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) + }) + + it('leaves the notice map identity alone when nothing is dismissable', () => { + switchAccount('pty-1', A, B) + const before = useAppStore.getState().codexRestartNoticeByPtyId + + useAppStore.getState().dismissCodexRestartNotices(['pty-unknown']) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(before) + }) + + it('still deletes the record when the pane actually restarts', () => { + switchAccount('pty-1', A, B) + + // Why: the relaunched pane genuinely runs under B, so its launch-account + // memory must reset rather than linger and suppress a later B -> A prompt. + useAppStore.getState().clearCodexRestartNotice('pty-1') + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + switchAccount('pty-1', B, A) + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: B, + nextAccountLabel: A + }) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 71c7c3c70a9..a55fba8b007 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -466,6 +466,10 @@ export type CodexRestartNotice = { * because `previousAccountLabel` is the only memory of the account this pane * actually launched under, which drives the A -> B -> A collapse. */ restartRequested?: true + /** Set when the user answers "Keep old account". Same reason the record has to + * survive: deleting it erased the launch account, so re-selecting it looked + * like a fresh switch and raised an inverted prompt that killed pane input. */ + dismissed?: true } export type TerminalSlice = { @@ -647,6 +651,7 @@ export type TerminalSlice = { notices: { ptyId: string; previousAccountLabel: string; nextAccountLabel: string }[] ) => void clearCodexRestartNotice: (ptyId: string) => void + dismissCodexRestartNotices: (ptyIds: string[]) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void @@ -2847,6 +2852,8 @@ export const createTerminalSlice: StateCreator nextAccountLabel: notice.nextAccountLabel, // Why: a queued restart relaunches under whatever account is selected // when it runs, so a later switch does not reopen an answered prompt. + // A dismissal is deliberately not carried over — it answered "keep the + // old account instead of B", which says nothing about a later C. ...(existing?.restartRequested ? { restartRequested: true as const } : {}) } } @@ -2873,6 +2880,34 @@ export const createTerminalSlice: StateCreator }) }, + 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 + } + }) + }, + setTabPaneExpanded: (tabId, expanded) => { set((s) => ({ expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded }