fix(codex): remember a dismissed restart notice instead of erasing the pane's launch account

Dismissing the Codex restart prompt deleted the notice record, and
`previousAccountLabel` on that record is the renderer's only memory of the
account the pane actually launched under. With it gone, re-selecting the pane's
original account read as a fresh switch and created an inverted notice
{previous: B, next: A} — and since `isCodexPaneStale` treats any notice as
stale, that false notice silently dropped every keystroke in the pane.

Keep the record and mark it `dismissed` instead. Two shared predicates
(`blocksCodexPaneInput`, `awaitsCodexRestartAnswer`) hold the deliberate
asymmetry between the two answered states in one place: a queued restart still
blocks input because the pane runs under the old account until it relaunches,
while a dismissal frees the keyboard immediately because keeping that account is
what the user asked for. A dismissal is not carried across a later switch, so a
genuinely different third account still prompts.
This commit is contained in:
Brennan Benson
2026-07-26 16:12:07 -07:00
parent 1818553aa6
commit 45a4ca1fa2
11 changed files with 475 additions and 26 deletions
@@ -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)
})
@@ -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)
@@ -1,3 +1,4 @@
import { awaitsCodexRestartAnswer } from './codex-restart-notice-state'
import type { AppState } from '@/store/types'
export type CodexRestartInputsState = Pick<AppState, 'ptyIdsByTabId' | 'codexRestartNoticeByPtyId'>
@@ -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 {
@@ -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 () => {
@@ -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
}
@@ -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({
@@ -1,3 +1,4 @@
import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state'
import type { CodexRestartNotice } from '@/store/slices/terminals'
type TabLookup = Record<string, { id: string }[]>
@@ -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
@@ -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<string, string>
@@ -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')
@@ -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
}
@@ -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
})
})
})
@@ -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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
})
},
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 }