diff --git a/src/renderer/src/components/terminal-pane/SessionRestoredBannerPortals.tsx b/src/renderer/src/components/terminal-pane/SessionRestoredBannerPortals.tsx new file mode 100644 index 000000000..ecc52befa --- /dev/null +++ b/src/renderer/src/components/terminal-pane/SessionRestoredBannerPortals.tsx @@ -0,0 +1,30 @@ +import { createPortal } from 'react-dom' +import { SessionRestoredBanner } from './SessionRestoredBanner' +import type { SessionRestoredBannerPane } from './session-restored-banner-pane-state' + +type SessionRestoredBannerPortalsProps = { + panes: readonly SessionRestoredBannerPane[] + paneIds: ReadonlySet +} + +export function SessionRestoredBannerPortals({ + panes, + paneIds +}: SessionRestoredBannerPortalsProps): React.JSX.Element { + return ( + <> + {panes.map((pane) => { + if (!paneIds.has(pane.id)) { + return null + } + return createPortal( + // Why: resumed TUIs repaint xterm immediately, so the wake marker + // must live in that pane's chrome instead of the PTY byte stream. + , + pane.container, + `session-restored-banner-${pane.id}` + ) + })} + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8cbc578f3..dcb0adbe2 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -40,8 +40,16 @@ import { TerminalErrorToast } from './TerminalErrorToast' import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog' import TerminalContextMenu from './TerminalContextMenu' import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog' -import { SessionRestoredBanner } from './SessionRestoredBanner' +import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals' import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss' +import { + addSessionRestoredBannerPaneId, + dismissSessionRestoredBannerPaneIds, + pruneSessionRestoredBannerPaneIds, + removeSessionRestoredBannerPaneId, + syncSessionRestoredBannerTitleSpace, + type SessionRestoredBannerDismissEvent +} from './session-restored-banner-pane-state' import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' @@ -418,8 +426,8 @@ 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 [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState>( + () => new Set() ) const shouldMeasureHiddenStartup = startup !== undefined && !isVisible const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) @@ -435,11 +443,30 @@ export default function TerminalPane({ } }, [startup, tabId, consumeTabStartupCommand]) - const dismissSessionRestoredBanner = useCallback((): void => { - setShowSessionRestoredBanner(false) + const clearSessionRestoredBannerForPane = useCallback((paneId: number): void => { + setSessionRestoredBannerPaneIds((prev) => { + const next = removeSessionRestoredBannerPaneId(prev, paneId) + return next === prev ? prev : next + }) }, []) + + const showRestoredSessionBanner = useCallback((paneId: number): void => { + setSessionRestoredBannerPaneIds((prev) => { + const next = addSessionRestoredBannerPaneId(prev, paneId) + return next === prev ? prev : next + }) + }, []) + + const dismissSessionRestoredBanner = useCallback( + (event: SessionRestoredBannerDismissEvent): void => { + setSessionRestoredBannerPaneIds((prev) => + dismissSessionRestoredBannerPaneIds(prev, event, managerRef.current?.getPanes() ?? []) + ) + }, + [] + ) useSessionRestoredBannerDismiss( - showSessionRestoredBanner, + sessionRestoredBannerPaneIds.size > 0, containerRef, dismissSessionRestoredBanner ) @@ -769,6 +796,7 @@ export default function TerminalPane({ // a single split pane doesn't go through closeTab. const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null closeWebRuntimeTerminal(ptyId) + clearSessionRestoredBannerForPane(paneId) const leafId = manager.getLeafId(paneId) if (leafId) { useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) @@ -778,7 +806,7 @@ export default function TerminalPane({ manager.closePane(paneId) } }, - [onCloseTab, syncPanePtyLayoutBinding, tabId] + [clearSessionRestoredBannerForPane, onCloseTab, syncPanePtyLayoutBinding, tabId] ) // Cmd+W handler — shows a confirmation dialog when the pane's shell has @@ -890,6 +918,7 @@ export default function TerminalPane({ clearWorktreeUnread, clearTerminalTabUnread, clearTerminalPaneUnread, + onShowSessionRestoredBanner: showRestoredSessionBanner, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -1098,6 +1127,7 @@ export default function TerminalPane({ clearWorktreeUnread, clearTerminalTabUnread, clearTerminalPaneUnread, + onShowSessionRestoredBanner: showRestoredSessionBanner, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding @@ -1117,6 +1147,7 @@ export default function TerminalPane({ clearWorktreeUnread, clearTerminalTabUnread, clearTerminalPaneUnread, + showRestoredSessionBanner, onPtyExitRef, setCacheTimerStartedAt, setRuntimePaneTitle, @@ -1506,29 +1537,30 @@ export default function TerminalPane({ if (!manager) { return } - let needsFit = false - for (const pane of manager.getPanes()) { - // 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 || showSessionRestoredBanner - const hadTitle = pane.container.hasAttribute('data-has-title') - if (shouldShow && !hadTitle) { - pane.container.setAttribute('data-has-title', '') - needsFit = true - } else if (!shouldShow && hadTitle) { - pane.container.removeAttribute('data-has-title') - needsFit = true - } - } + // 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 this space; its overlay should not reflow terminal content. + const needsFit = syncSessionRestoredBannerTitleSpace({ + panes: manager.getPanes(), + paneTitles, + renamingPaneId, + sessionRestoredBannerPaneIds + }) if (needsFit) { fitPanes(manager) } - }, [paneCount, paneTitles, renamingPaneId, showSessionRestoredBanner]) + }, [paneCount, paneTitles, renamingPaneId, sessionRestoredBannerPaneIds]) + + useEffect(() => { + const manager = managerRef.current + if (!manager) { + return + } + setSessionRestoredBannerPaneIds((prev) => { + const next = pruneSessionRestoredBannerPaneIds(prev, manager.getPanes()) + return next === prev ? prev : next + }) + }, [paneCount]) // Register a capture callback for shutdown. The beforeunload handler in // App.tsx calls all registered callbacks to serialize terminal buffers. @@ -2009,15 +2041,10 @@ 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. - , - activePane.container, - 'session-restored-banner' - )} + void clearTerminalTabUnread: (tabId: string) => void clearTerminalPaneUnread: (paneKey: string) => void + onShowSessionRestoredBanner: (paneId: number) => void // Why: the renderer dispatches two notification sources — BEL from the PTY // byte stream and agent-task-complete on the working→idle title transition. // shared/types.ts keeps a wider NotificationEventSource union because the 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 f40723269..674fcb07b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -364,6 +364,7 @@ function createDeps(overrides: Record = {}) { clearTerminalTabUnread: vi.fn(), clearTerminalPaneUnread: vi.fn(), dispatchNotification: vi.fn(), + onShowSessionRestoredBanner: vi.fn(), setCacheTimerStartedAt: vi.fn(), syncPanePtyLayoutBinding: vi.fn(), ...overrides @@ -3155,17 +3156,14 @@ 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(pane.terminal.write).not.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(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBe(-1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1) expect(transport.sendInput).toHaveBeenCalledWith( "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r" ) @@ -3245,10 +3243,12 @@ describe('connectPanePty', () => { expect.not.objectContaining({ sessionId: expect.any(String) }) ) expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId) - expect(pane.terminal.write).toHaveBeenCalledWith( + expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('--- session restored ---'), expect.any(Function) ) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2) expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index d199bd20d..83483b02a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -129,8 +129,6 @@ 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 keys: () => string[] @@ -1974,20 +1972,13 @@ 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) { + let sessionRestoredBannerShown = false + const showSessionRestoredBanner = (): void => { + if (sessionRestoredBannerShown) { return } - sessionRestoredBannerWritten = true - if (writeBanner) { - writeBanner(SESSION_RESTORED_BANNER) - return - } - writeTerminalOutput(pane.terminal, SESSION_RESTORED_BANNER, { - foreground: true, - beforeWrite: beforeTerminalOutputWrite - }) + sessionRestoredBannerShown = true + deps.onShowSessionRestoredBanner(pane.id) } const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => { if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { @@ -1995,9 +1986,7 @@ export function connectPanePty( } return CLIENT_PLATFORM } - const prepareColdRestoreAgentResumeCommand = ( - writeBanner?: (data: string) => void - ): boolean => { + const prepareColdRestoreAgentResumeCommand = (): boolean => { if (pendingStartupCommand) { return false } @@ -2030,7 +2019,7 @@ export function connectPanePty( // session is still resumable, so the replacement shell must launch it. pendingStartupCommand = startupPlan.launchCommand if (sleepingRecord) { - writeSessionRestoredBanner(writeBanner) + showSessionRestoredBanner() } if (!useLiveEntry && sleepingRecord) { state.clearSleepingAgentSession(cacheKey) @@ -3132,7 +3121,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) - const didPrepareResume = prepareColdRestoreAgentResumeCommand(writeReplayData) + const didPrepareResume = prepareColdRestoreAgentResumeCommand() // 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 diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx new file mode 100644 index 000000000..db8a13faa --- /dev/null +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx @@ -0,0 +1,148 @@ +// @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 } from './SessionRestoredBanner' +import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals' +import { + addSessionRestoredBannerPaneId, + dismissSessionRestoredBannerPaneIds, + pruneSessionRestoredBannerPaneIds, + removeSessionRestoredBannerPaneId, + seedStartupSessionRestoredBanner, + syncSessionRestoredBannerTitleSpace, + type SessionRestoredBannerPane +} from './session-restored-banner-pane-state' + +const mountedRoots: Root[] = [] + +function createPane(id: number): SessionRestoredBannerPane { + const container = document.createElement('div') + container.className = 'pane' + container.dataset.leafId = `leaf-${id}` + document.body.appendChild(container) + return { id, container } +} + +async function renderPortals( + panes: readonly SessionRestoredBannerPane[], + paneIds: ReadonlySet +): Promise { + const rootContainer = document.createElement('div') + document.body.appendChild(rootContainer) + const root = createRoot(rootContainer) + mountedRoots.push(root) + await act(async () => { + root.render() + }) +} + +function eventFrom(target: HTMLElement, event: KeyboardEvent | PointerEvent): typeof event { + target.dispatchEvent(event) + return event +} + +function paneText(pane: SessionRestoredBannerPane): string { + return pane.container.textContent ?? '' +} + +describe('session restored banner pane state', () => { + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + + it('seeds sidebar startup onto the created pane and renders its overlay there', async () => { + const firstPane = createPane(1) + const createdPane = createPane(2) + let paneIds = new Set() + + seedStartupSessionRestoredBanner( + { showSessionRestoredBanner: true }, + createdPane.id, + (paneId) => { + paneIds = addSessionRestoredBannerPaneId(paneIds, paneId) + } + ) + await renderPortals([firstPane, createdPane], paneIds) + + expect(paneIds).toEqual(new Set([createdPane.id])) + expect(paneText(firstPane)).toBe('') + expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT) + }) + + it('renders and reserves title space only on the restored inactive split pane', async () => { + const activePane = createPane(1) + const inactiveRestoredPane = createPane(2) + const paneIds = new Set([inactiveRestoredPane.id]) + + const needsFit = syncSessionRestoredBannerTitleSpace({ + panes: [activePane, inactiveRestoredPane], + paneTitles: {}, + renamingPaneId: null, + sessionRestoredBannerPaneIds: paneIds + }) + await renderPortals([activePane, inactiveRestoredPane], paneIds) + + expect(needsFit).toBe(true) + expect(activePane.container.hasAttribute('data-has-title')).toBe(false) + expect(inactiveRestoredPane.container.hasAttribute('data-has-title')).toBe(true) + expect(paneText(activePane)).toBe('') + expect(paneText(inactiveRestoredPane)).toBe(SESSION_RESTORED_BANNER_TEXT) + }) + + it('dismisses only the interacted pane for pointer and key events', () => { + const firstPane = createPane(1) + const secondPane = createPane(2) + const firstChild = document.createElement('button') + const secondChild = document.createElement('button') + firstPane.container.appendChild(firstChild) + secondPane.container.appendChild(secondChild) + + const afterPointer = dismissSessionRestoredBannerPaneIds( + new Set([firstPane.id, secondPane.id]), + eventFrom(secondChild, new PointerEvent('pointerdown', { bubbles: true })), + [firstPane, secondPane] + ) + const afterKey = dismissSessionRestoredBannerPaneIds( + new Set([firstPane.id, secondPane.id]), + eventFrom(firstChild, new KeyboardEvent('keydown', { bubbles: true })), + [firstPane, secondPane] + ) + + expect(afterPointer).toEqual(new Set([firstPane.id])) + expect(afterKey).toEqual(new Set([secondPane.id])) + }) + + it('clears all restored banners when dismissal cannot resolve a pane', () => { + const firstPane = createPane(1) + const secondPane = createPane(2) + const outside = document.createElement('button') + document.body.appendChild(outside) + + const afterDismiss = dismissSessionRestoredBannerPaneIds( + new Set([firstPane.id, secondPane.id]), + eventFrom(outside, new PointerEvent('pointerdown', { bubbles: true })), + [firstPane, secondPane] + ) + + expect(afterDismiss).toEqual(new Set()) + }) + + it('clears banners for closed or removed panes', () => { + const firstPane = createPane(1) + const secondPane = createPane(2) + + expect(removeSessionRestoredBannerPaneId(new Set([firstPane.id, secondPane.id]), 2)).toEqual( + new Set([firstPane.id]) + ) + expect( + pruneSessionRestoredBannerPaneIds(new Set([firstPane.id, secondPane.id]), [firstPane]) + ).toEqual(new Set([firstPane.id])) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts new file mode 100644 index 000000000..03fd87ad6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts @@ -0,0 +1,108 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' + +export type SessionRestoredBannerPane = Pick + +export type SessionRestoredBannerStartup = + | { + showSessionRestoredBanner?: boolean + } + | null + | undefined + +export type SessionRestoredBannerDismissEvent = KeyboardEvent | PointerEvent + +export function addSessionRestoredBannerPaneId( + paneIds: ReadonlySet, + paneId: number +): Set { + if (paneIds.has(paneId)) { + return paneIds instanceof Set ? paneIds : new Set(paneIds) + } + return new Set(paneIds).add(paneId) +} + +export function removeSessionRestoredBannerPaneId( + paneIds: ReadonlySet, + paneId: number +): Set { + if (!paneIds.has(paneId)) { + return paneIds instanceof Set ? paneIds : new Set(paneIds) + } + const next = new Set(paneIds) + next.delete(paneId) + return next +} + +export function pruneSessionRestoredBannerPaneIds( + paneIds: ReadonlySet, + panes: readonly SessionRestoredBannerPane[] +): Set { + const livePaneIds = new Set(panes.map((pane) => pane.id)) + if ([...paneIds].every((paneId) => livePaneIds.has(paneId))) { + return paneIds instanceof Set ? paneIds : new Set(paneIds) + } + return new Set([...paneIds].filter((paneId) => livePaneIds.has(paneId))) +} + +export function getSessionRestoredBannerDismissPaneId( + event: SessionRestoredBannerDismissEvent, + panes: readonly SessionRestoredBannerPane[] +): number | null { + const targetElement = + event.target instanceof Element + ? event.target + : event.target instanceof Node + ? event.target.parentElement + : null + const paneElement = targetElement?.closest('.pane[data-leaf-id]') + if (!paneElement) { + return null + } + return panes.find((pane) => pane.container === paneElement)?.id ?? null +} + +export function dismissSessionRestoredBannerPaneIds( + paneIds: ReadonlySet, + event: SessionRestoredBannerDismissEvent, + panes: readonly SessionRestoredBannerPane[] +): Set { + const paneId = getSessionRestoredBannerDismissPaneId(event, panes) + if (paneId === null) { + return new Set() + } + return removeSessionRestoredBannerPaneId(paneIds, paneId) +} + +export function seedStartupSessionRestoredBanner( + startup: SessionRestoredBannerStartup, + paneId: number, + onShowSessionRestoredBanner: (paneId: number) => void +): void { + if (startup?.showSessionRestoredBanner === true) { + onShowSessionRestoredBanner(paneId) + } +} + +export function syncSessionRestoredBannerTitleSpace(args: { + panes: readonly SessionRestoredBannerPane[] + paneTitles: Readonly> + renamingPaneId: number | null + sessionRestoredBannerPaneIds: ReadonlySet +}): boolean { + let needsFit = false + for (const pane of args.panes) { + const shouldShow = + !!args.paneTitles[pane.id] || + args.renamingPaneId === pane.id || + args.sessionRestoredBannerPaneIds.has(pane.id) + const hadTitle = pane.container.hasAttribute('data-has-title') + if (shouldShow && !hadTitle) { + pane.container.setAttribute('data-has-title', '') + needsFit = true + } else if (!shouldShow && hadTitle) { + pane.container.removeAttribute('data-has-title') + needsFit = true + } + } + return needsFit +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 558579482..f187c3634 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -79,6 +79,7 @@ import { } from '@/constants/terminal' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' +import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state' export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, @@ -115,10 +116,15 @@ type UseTerminalPaneLifecycleDeps = { cwd?: string startup?: { command: string + /** Renderer-delivered startup input for callers that need xterm paste + * semantics before the submit Enter. */ + delivery?: 'terminal-paste' env?: Record /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` * so main fires the event only after the spawn succeeds. */ telemetry?: EventProps<'agent_started'> + /** Show the restored-session banner when this startup command mounts. */ + showSessionRestoredBanner?: boolean } | null /** When present, the initial pane boots clean and a split pane is created * (vertical or horizontal per the user setting) to run the setup command — @@ -174,6 +180,7 @@ type UseTerminalPaneLifecycleDeps = { clearWorktreeUnread: (worktreeId: string) => void clearTerminalTabUnread: (tabId: string) => void clearTerminalPaneUnread: (paneKey: string) => void + onShowSessionRestoredBanner: (paneId: number) => void dispatchNotification: (event: { source: 'terminal-bell' | 'agent-task-complete' terminalTitle?: string @@ -356,6 +363,7 @@ export function useTerminalPaneLifecycle({ clearWorktreeUnread, clearTerminalTabUnread, clearTerminalPaneUnread, + onShowSessionRestoredBanner, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -542,6 +550,7 @@ export function useTerminalPaneLifecycle({ clearWorktreeUnread, clearTerminalTabUnread, clearTerminalPaneUnread, + onShowSessionRestoredBanner, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -708,6 +717,7 @@ export function useTerminalPaneLifecycle({ requestOpenLinksInAppPreference }) httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable) + seedStartupSessionRestoredBanner(ptyDeps.startup, pane.id, onShowSessionRestoredBanner) // Why: skip empty selections so clicking to deselect doesn't clobber // whatever the user last copied elsewhere. const selectionDisposable = pane.terminal.onSelectionChange(() => { diff --git a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx index f9c893de7..3c13aea87 100644 --- a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx +++ b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx @@ -45,6 +45,7 @@ describe('useSessionRestoredBannerDismiss', () => { pane.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) expect(dismiss).toHaveBeenCalledTimes(1) + expect(dismiss).toHaveBeenCalledWith(expect.any(KeyboardEvent)) }) it('dismisses the banner on pane pointer interaction', async () => { @@ -54,6 +55,7 @@ describe('useSessionRestoredBannerDismiss', () => { pane.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) expect(dismiss).toHaveBeenCalledTimes(1) + expect(dismiss).toHaveBeenCalledWith(expect.any(PointerEvent)) }) it('does not attach dismissal handlers when the banner is hidden', async () => { diff --git a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.ts b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.ts index e2cf7e477..9f32db72e 100644 --- a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.ts +++ b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.ts @@ -1,9 +1,10 @@ import { useEffect, type RefObject } from 'react' +import type { SessionRestoredBannerDismissEvent } from './session-restored-banner-pane-state' export function useSessionRestoredBannerDismiss( visible: boolean, containerRef: RefObject, - dismiss: () => void + dismiss: (event: SessionRestoredBannerDismissEvent) => void ): void { useEffect(() => { if (!visible) {