Show restored agent sessions in pane chrome (#5568)

This commit is contained in:
Brennan Benson 2026-06-16 23:09:43 -07:00 committed by GitHub
parent 98120417f2
commit 928c1ee106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 380 additions and 64 deletions

View File

@ -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<number>
}
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.
<SessionRestoredBanner visible />,
pane.container,
`session-restored-banner-${pane.id}`
)
})}
</>
)
}

View File

@ -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<Set<number>>(
() => 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.
<SessionRestoredBanner visible />,
activePane.container,
'session-restored-banner'
)}
<SessionRestoredBannerPortals
panes={managerRef.current?.getPanes() ?? []}
paneIds={sessionRestoredBannerPaneIds}
/>
<TerminalContextMenu
open={contextMenu.open}
onOpenChange={contextMenu.setOpen}

View File

@ -45,6 +45,7 @@ export type PtyConnectionDeps = {
clearWorktreeUnread: (worktreeId: string) => 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

View File

@ -364,6 +364,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
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)
})

View File

@ -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

View File

@ -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<number>
): Promise<void> {
const rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
const root = createRoot(rootContainer)
mountedRoots.push(root)
await act(async () => {
root.render(<SessionRestoredBannerPortals panes={panes} paneIds={paneIds} />)
})
}
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<number>()
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]))
})
})

View File

@ -0,0 +1,108 @@
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
export type SessionRestoredBannerPane = Pick<ManagedPane, 'id' | 'container'>
export type SessionRestoredBannerStartup =
| {
showSessionRestoredBanner?: boolean
}
| null
| undefined
export type SessionRestoredBannerDismissEvent = KeyboardEvent | PointerEvent
export function addSessionRestoredBannerPaneId(
paneIds: ReadonlySet<number>,
paneId: number
): Set<number> {
if (paneIds.has(paneId)) {
return paneIds instanceof Set ? paneIds : new Set(paneIds)
}
return new Set(paneIds).add(paneId)
}
export function removeSessionRestoredBannerPaneId(
paneIds: ReadonlySet<number>,
paneId: number
): Set<number> {
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<number>,
panes: readonly SessionRestoredBannerPane[]
): Set<number> {
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<number>,
event: SessionRestoredBannerDismissEvent,
panes: readonly SessionRestoredBannerPane[]
): Set<number> {
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<Record<number, string>>
renamingPaneId: number | null
sessionRestoredBannerPaneIds: ReadonlySet<number>
}): 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
}

View File

@ -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<string, string>
/** 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(() => {

View File

@ -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 () => {

View File

@ -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<HTMLElement | null>,
dismiss: () => void
dismiss: (event: SessionRestoredBannerDismissEvent) => void
): void {
useEffect(() => {
if (!visible) {