Show restored banner when slept agent resumes (#5467)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-16 13:14:00 -07:00 committed by GitHub
parent bce7b7948a
commit ef04f10dad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 362 additions and 11 deletions

View File

@ -261,13 +261,29 @@
align-items: center;
padding: 0 8px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-family: var(--font-mono);
color: var(--orca-pane-title-fg, rgb(255 255 255 / 0.52));
background: transparent;
border-bottom: none;
user-select: none;
}
.session-restored-banner {
position: absolute;
top: 0;
left: 8px;
z-index: 5;
height: var(--orca-pane-title-height);
display: flex;
align-items: center;
font-family: var(--font-mono);
font-size: 12px;
color: var(--orca-pane-title-fg);
background: transparent;
user-select: none;
pointer-events: none;
}
.pane-title-text {
align-self: stretch;
display: block;

View File

@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { SESSION_RESTORED_BANNER_TEXT, SessionRestoredBanner } from './SessionRestoredBanner'
const mountedRoots: Root[] = []
async function renderBanner(visible: boolean): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(<SessionRestoredBanner visible={visible} />)
})
return container
}
describe('SessionRestoredBanner', () => {
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
})
it('renders the exact restored-session marker when visible', async () => {
const container = await renderBanner(true)
expect(container.textContent).toBe(SESSION_RESTORED_BANNER_TEXT)
expect(container.querySelector('.session-restored-banner')).not.toBeNull()
})
it('does not render without the startup marker', async () => {
const container = await renderBanner(false)
expect(container.textContent).toBe('')
expect(container.querySelector('.session-restored-banner')).toBeNull()
})
})

View File

@ -0,0 +1,15 @@
export const SESSION_RESTORED_BANNER_TEXT = '--- session restored ---'
type SessionRestoredBannerProps = {
visible: boolean
}
export function SessionRestoredBanner({
visible
}: SessionRestoredBannerProps): React.JSX.Element | null {
if (!visible) {
return null
}
return <div className="session-restored-banner">{SESSION_RESTORED_BANNER_TEXT}</div>
}

View File

@ -40,6 +40,8 @@ import { TerminalErrorToast } from './TerminalErrorToast'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import TerminalContextMenu from './TerminalContextMenu'
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
import { SessionRestoredBanner } from './SessionRestoredBanner'
import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss'
import { useSystemPrefersDark } from './use-system-prefers-dark'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
@ -413,6 +415,9 @@ export default function TerminalPane({
// xterm may not know multi-line text needs bracketed-paste protection.
const forceBracketedMultilineTextPaste = isWindowsUserAgent()
const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId])
const [showSessionRestoredBanner, setShowSessionRestoredBanner] = useState(
() => startup?.showSessionRestoredBanner === true
)
const shouldMeasureHiddenStartup = startup !== undefined && !isVisible
const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand)
const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId])
@ -427,6 +432,15 @@ export default function TerminalPane({
}
}, [startup, tabId, consumeTabStartupCommand])
const dismissSessionRestoredBanner = useCallback((): void => {
setShowSessionRestoredBanner(false)
}, [])
useSessionRestoredBannerDismiss(
showSessionRestoredBanner,
containerRef,
dismissSessionRestoredBanner
)
const openDiskSpaceAnalyzer = useCallback(() => {
setSessionStateSaveFailureOpen(false)
openSpacePage()
@ -1467,13 +1481,14 @@ export default function TerminalPane({
}
let needsFit = false
for (const pane of manager.getPanes()) {
// Show the title bar space when the pane has a title OR is being
// inline-edited (so the input appears even for untitled panes).
// Show the title bar space when the pane has a title, is being
// inline-edited, or has transient startup chrome.
// Unread activity does NOT reserve title-bar space — the bell is
// rendered as an absolutely-positioned overlay in the pane's top-right
// corner so it can appear and disappear without shifting terminal
// content, avoiding the jarring reflow on bell toggles.
const shouldShow = !!paneTitles[pane.id] || renamingPaneId === pane.id
const shouldShow =
!!paneTitles[pane.id] || renamingPaneId === pane.id || showSessionRestoredBanner
const hadTitle = pane.container.hasAttribute('data-has-title')
if (shouldShow && !hadTitle) {
pane.container.setAttribute('data-has-title', '')
@ -1486,7 +1501,7 @@ export default function TerminalPane({
if (needsFit) {
fitPanes(manager)
}
}, [paneTitles, renamingPaneId])
}, [paneCount, paneTitles, renamingPaneId, showSessionRestoredBanner])
// Register a capture callback for shutdown. The beforeunload handler in
// App.tsx calls all registered callbacks to serialize terminal buffers.
@ -1967,6 +1982,15 @@ export default function TerminalPane({
/>,
activePane.container
)}
{showSessionRestoredBanner &&
activePane?.container &&
createPortal(
// Why: resumed Codex TUIs repaint xterm immediately, so the wake marker
// must live in the pane chrome instead of the PTY byte stream.
<SessionRestoredBanner visible />,
activePane.container,
'session-restored-banner'
)}
<TerminalContextMenu
open={contextMenu.open}
onOpenChange={contextMenu.setOpen}

View File

@ -20,6 +20,8 @@ export type PtyConnectionDeps = {
telemetry?: EventProps<'agent_started'>
/** Initial prompt-start status for agents that lack native prompt hooks. */
initialAgentStatus?: { agent: TuiAgent; prompt: string }
/** Show the restored-session banner when this startup command mounts. */
showSessionRestoredBanner?: boolean
} | null
restoredLeafId?: string | null
restoredPtyIdByLeafId?: Record<string, string>

View File

@ -3092,6 +3092,10 @@ describe('connectPanePty', () => {
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
@ -3150,6 +3154,17 @@ describe('connectPanePty', () => {
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
const writeCalls = pane.terminal.write.mock.calls.map(([data]) => data)
expect(writeCalls.indexOf('cold-payload')).toBeLessThan(
writeCalls.findIndex((data) => data.includes('--- session restored ---'))
)
expect(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBeLessThan(
writeCalls.indexOf(POST_REPLAY_MODE_RESET)
)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
@ -3158,6 +3173,114 @@ describe('connectPanePty', () => {
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('shows the restored banner when a sleeping resume falls back to a fresh shell', async () => {
const { connectPanePty } = await import('./pty-connection')
const staleSessionId = 'wt-1@@stale-session'
const transport = createMockTransport()
transport.connect.mockImplementation(async (opts: { sessionId?: string }) => {
if (opts.sessionId) {
return undefined
}
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.('fresh-pty')
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_2)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }]
},
ptyIdsByTabId: {
'tab-1': [staleSessionId]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: staleSessionId }
}
},
settings: {
...mockStoreState.settings,
agentCmdOverrides: {}
},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
const pane = createPane(2)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(10)
expect(transport.connect).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ sessionId: staleSessionId })
)
expect(transport.connect).toHaveBeenNthCalledWith(
2,
expect.not.objectContaining({ sessionId: expect.any(String) })
)
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId)
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-1')
transportFactoryQueue.push(transport)
const pane = createPane(1)
connectPanePty(
pane as never,
createManager(1) as never,
createDeps({
startup: {
command: "codex 'resume' 'codex-session-1'",
showSessionRestoredBanner: true
}
}) as never
)
await flushAsyncTicks(10)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.('pty-1')
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
expect(createdTransportOptions[0]?.command).toBe("codex 'resume' 'codex-session-1'")
})
it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')

View File

@ -125,6 +125,7 @@ const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024
// terminal state is unavailable, so the user has an explicit loss signal.
const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
const SESSION_RESTORED_BANNER = '\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n'
type E2eTerminalPtyDataInjectionApi = {
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
@ -1960,13 +1961,30 @@ export function connectPanePty(
// stay renderer-delivered so xterm can apply bracketed-paste semantics.
let pendingStartupCommand =
shouldDeliverStartupViaTerminalPaste || connectionId ? (paneStartup?.command ?? null) : null
let sessionRestoredBannerWritten = false
const writeSessionRestoredBanner = (writeBanner?: (data: string) => void): void => {
if (sessionRestoredBannerWritten) {
return
}
sessionRestoredBannerWritten = true
if (writeBanner) {
writeBanner(SESSION_RESTORED_BANNER)
return
}
writeTerminalOutput(pane.terminal, SESSION_RESTORED_BANNER, {
foreground: true,
beforeWrite: beforeTerminalOutputWrite
})
}
const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => {
if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) {
return 'linux'
}
return CLIENT_PLATFORM
}
const prepareColdRestoreAgentResumeCommand = (): boolean => {
const prepareColdRestoreAgentResumeCommand = (
writeBanner?: (data: string) => void
): boolean => {
if (pendingStartupCommand) {
return false
}
@ -1998,6 +2016,9 @@ export function connectPanePty(
// Why: cold restore means the PTY process is gone but the agent provider
// session is still resumable, so the replacement shell must launch it.
pendingStartupCommand = startupPlan.launchCommand
if (sleepingRecord) {
writeSessionRestoredBanner(writeBanner)
}
if (!useLiveEntry && sleepingRecord) {
state.clearSleepingAgentSession(cacheKey)
}
@ -3097,7 +3118,7 @@ export function connectPanePty(
// land in the new shell's stdin. See replay-guard.ts.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.coldRestore.scrollback)
writeReplayData('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n')
const didPrepareResume = prepareColdRestoreAgentResumeCommand(writeReplayData)
// Cold-restore means the daemon lost the session and spawned a
// fresh shell — no TUI is consuming the mode-setting bytes that a
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
@ -3106,7 +3127,7 @@ export function connectPanePty(
if (!isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
if (prepareColdRestoreAgentResumeCommand()) {
if (didPrepareResume) {
schedulePendingStartupCommandDelivery()
}
}

View File

@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { useRef } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss'
const mountedRoots: Root[] = []
function Probe({ visible, dismiss }: { visible: boolean; dismiss: () => void }): React.JSX.Element {
const ref = useRef<HTMLDivElement | null>(null)
useSessionRestoredBannerDismiss(visible, ref, dismiss)
return <div ref={ref} data-testid="pane" />
}
async function renderProbe(visible: boolean, dismiss = vi.fn()): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(<Probe visible={visible} dismiss={dismiss} />)
})
return container.querySelector('[data-testid="pane"]')!
}
describe('useSessionRestoredBannerDismiss', () => {
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
vi.clearAllMocks()
})
it('dismisses the banner on pane keyboard input', async () => {
const dismiss = vi.fn()
const pane = await renderProbe(true, dismiss)
pane.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }))
expect(dismiss).toHaveBeenCalledTimes(1)
})
it('dismisses the banner on pane pointer interaction', async () => {
const dismiss = vi.fn()
const pane = await renderProbe(true, dismiss)
pane.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(dismiss).toHaveBeenCalledTimes(1)
})
it('does not attach dismissal handlers when the banner is hidden', async () => {
const dismiss = vi.fn()
const pane = await renderProbe(false, dismiss)
pane.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }))
pane.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(dismiss).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,23 @@
import { useEffect, type RefObject } from 'react'
export function useSessionRestoredBannerDismiss(
visible: boolean,
containerRef: RefObject<HTMLElement | null>,
dismiss: () => void
): void {
useEffect(() => {
if (!visible) {
return
}
const container = containerRef.current
if (!container) {
return
}
container.addEventListener('keydown', dismiss, { capture: true })
container.addEventListener('pointerdown', dismiss, { capture: true })
return () => {
container.removeEventListener('keydown', dismiss, { capture: true })
container.removeEventListener('pointerdown', dismiss, { capture: true })
}
}, [visible, containerRef, dismiss])
}

View File

@ -71,6 +71,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
const state = useAppStore.getState()
const resumedTab = (state.tabsByWorktree['wt-1'] ?? []).find((tab) => tab.id !== 'tab-1')
expect(resumedTab?.launchAgent).toBe('claude')
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
@ -88,6 +89,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
const tabs = state.tabsByWorktree['wt-1'] ?? []
expect(tabs).toHaveLength(1)
expect(tabs[0]?.launchAgent).toBe('claude')
expect(state.pendingStartupByTabId[tabs[0]!.id]?.showSessionRestoredBanner).toBe(true)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
})

View File

@ -65,6 +65,7 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
})
state.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
showSessionRestoredBanner: true,
telemetry: {
agent_kind: tuiAgentToAgentKind(record.agent),
launch_source: 'sidebar',

View File

@ -295,6 +295,7 @@ describe('activateAndRevealWorktree created agent reopen', () => {
expect(resumedTab?.launchAgent).toBe('codex')
expect(state.pendingStartupByTabId[resumedTab!.id]).toEqual({
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
showSessionRestoredBanner: true,
telemetry: {
agent_kind: 'codex',
launch_source: 'sidebar',

View File

@ -100,6 +100,7 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
command: string
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void

View File

@ -301,6 +301,8 @@ export type TerminalSlice = {
env?: Record<string, string>
/** Initial prompt-start status for agents that lack native prompt hooks. */
initialAgentStatus?: { agent: TuiAgent; prompt: string }
/** Show the restored-session banner when this startup command mounts. */
showSessionRestoredBanner?: boolean
/** Telemetry metadata for the `agent_started` event. Threaded all the
* way to the `pty:spawn` IPC handler in main so the event fires only
* after spawn confirms never on click-intent. */
@ -438,12 +440,18 @@ export type TerminalSlice = {
delivery?: 'terminal-paste'
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void
consumeTabStartupCommand: (
tabId: string
) => { command: string; env?: Record<string, string>; telemetry?: AgentStartedTelemetry } | null
consumeTabStartupCommand: (tabId: string) => {
command: string
delivery?: 'terminal-paste'
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
} | null
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }