Show restored agent sessions in pane chrome (#5568)
This commit is contained in:
parent
98120417f2
commit
928c1ee106
|
|
@ -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}`
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue