mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user