fix(session-tabs): retire terminal rows the host closed, and offer Close Pane on a closed remote terminal (#33949)

* fix(session-tabs): retire kept terminal rows the host closed, and offer Close Pane on a closed remote terminal

* fix(session-tabs): a live handle outranks an older close record
This commit is contained in:
Neil
2026-10-26 07:16:04 -07:00
committed by GitHub
parent 6c815e80c8
commit 20f7bb6a69
6 changed files with 208 additions and 1 deletions
@@ -660,3 +660,28 @@ it('uses existing Korean recovery copy and preserves the path', async () => {
await i18n.changeLanguage('en')
}
})
// #21342: dismissing hid the toast but left the dead pane with no way out.
it('offers Close Pane only for a pane whose remote terminal was closed', () => {
const onClosePane = vi.fn()
const view = render(
React.createElement(TerminalErrorToast, {
error: 'Remote terminal was closed.',
paneOnClient: false,
onDismiss: vi.fn(),
onClosePane
})
)
fireEvent.click(view.getByText('Close Pane'))
expect(onClosePane).toHaveBeenCalledTimes(1)
view.rerender(
React.createElement(TerminalErrorToast, {
error: 'Paste failed.',
paneOnClient: false,
onDismiss: vi.fn(),
onClosePane
})
)
expect(view.queryByText('Close Pane')).toBeNull()
})
@@ -99,6 +99,11 @@ export function isExplainedTerminalError(error: string): boolean {
)
}
/** The host no longer has this pane's terminal, so nothing can come back into it. */
export function isRemoteTerminalClosedError(error: string): boolean {
return error.includes(REMOTE_TERMINAL_CLOSED_MARKER)
}
export function isPaneOwnerUnverifiedError(error: string): boolean {
const lines = error.split('\n').filter((line) => line.length > 0)
return lines.length > 0 && lines.every((line) => line.includes(PANE_OWNER_UNVERIFIED_MARKER))
@@ -221,7 +226,8 @@ export function TerminalErrorToast({
onDismiss,
onRestartDaemon,
onRetry,
onOpenNewTerminal
onOpenNewTerminal,
onClosePane
}: {
error: string
agentId?: string | null
@@ -232,6 +238,8 @@ export function TerminalErrorToast({
onRetry?: () => Promise<boolean>
/** Leaves this pane and its saved session untouched. */
onOpenNewTerminal?: () => void
/** Dismissing only hides the toast; the dead pane needs a way out from where the user is looking (#21342). */
onClosePane?: () => void
}): React.JSX.Element {
const ssh = isSshHostStateTerminalError(error)
// Why: the client's OS and shell describe neither the host nor its shell, and the renderer knows neither.
@@ -404,6 +412,14 @@ export function TerminalErrorToast({
)}
</Button>
) : null}
{onClosePane && isRemoteTerminalClosedError(error) ? (
<Button variant="outline" size="xs" onClick={onClosePane} className="ml-3">
{translate(
'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d',
'Close Pane'
)}
</Button>
) : null}
<button
onClick={onDismiss}
style={{
@@ -214,6 +214,7 @@ export function TerminalPaneSurface({
onOpenNewTerminal={() => {
openTerminalBesideTab(tabId)
}}
onClosePane={() => handleRequestClosePane(activePane.id)}
/>,
activePane.container,
`terminal-error-${activePane.id}`
@@ -100,4 +100,96 @@ describe('restored placeholders retire only by their own identity', () => {
expect(patch.tabsByWorktree?.[WT]?.map((tab) => tab.id)).not.toContain(restored.id)
expect(patch.tabsByWorktree?.[WT]).toHaveLength(1)
})
// #11800: the host's close record is the identity a ghost row needs; without it the row stayed forever.
it('retires a kept row once the host publishes a close record for every pane it holds', () => {
const closed = restoredRow('restored-a', 0)
const open = restoredRow('restored-b', 1)
const split = restoredRow('restored-c', 2)
const {
pendingActivationSpawn: _spawn,
restoredFromSession: _restored,
...base
} = restoredRow('client-d', 3)
const clientMinted: TerminalTab = { ...base, ptyId: 'remote:web-env-1@@gone-d' }
const unbound = restoredRow('restored-e', 4)
const revived: TerminalTab = { ...base, id: 'client-g', ptyId: 'remote:web-env-1@@gone-g' }
const retired = (parentTabId: string, leafId: string, terminal: string) => ({
parentTabId,
leafId,
terminal,
ptyId: `host-${terminal}`
})
const patch: Partial<WebSessionTabsSyncState> = applyWebSessionTabsSnapshot(
makeState({
tabsByWorktree: { [WT]: [closed, open, split, clientMinted, unbound, revived] },
terminalLayoutsByTabId: {
[closed.id]: restoredLayout(SECOND_LEAF_ID, 'remote:web-env-1@@gone-a'),
[open.id]: restoredLayout(THIRD_LEAF_ID, 'remote:web-env-1@@gone-b'),
[split.id]: {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: SECOND_LEAF_ID },
second: { type: 'leaf' as const, leafId: THIRD_LEAF_ID }
},
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[SECOND_LEAF_ID]: 'remote:web-env-1@@gone-c1',
[THIRD_LEAF_ID]: 'remote:web-env-1@@gone-c2'
}
},
[revived.id]: {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: SECOND_LEAF_ID },
second: { type: 'leaf' as const, leafId: THIRD_LEAF_ID }
},
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[SECOND_LEAF_ID]: 'remote:web-env-1@@gone-g',
[THIRD_LEAF_ID]: 'remote:web-env-1@@revived-h'
}
}
}
}),
makeSnapshot(
[
unrelatedReadySurface,
// A handle survives its PTY's re-incarnation, so a live one outranks an older close record.
{
...unrelatedReadySurface,
id: 'host-surface-h',
parentTabId: 'host-tab-h',
isActive: false,
terminal: 'revived-h'
}
],
{
retiredTerminalSurfaces: [
retired(closed.id, SECOND_LEAF_ID, 'gone-a'),
retired(split.id, SECOND_LEAF_ID, 'gone-c1'),
retired('host-tab-d', LEAF_ID, 'gone-d'),
retired(unbound.id, LEAF_ID, 'gone-e'),
retired('host-tab-g', SECOND_LEAF_ID, 'gone-g'),
retired('host-tab-g', THIRD_LEAF_ID, 'revived-h')
]
}
),
ENV,
NOW
)
const ids = patch.tabsByWorktree?.[WT]?.map((tab) => tab.id) ?? []
expect(ids).not.toContain(closed.id)
expect(ids).not.toContain(clientMinted.id)
expect(ids).not.toContain(unbound.id)
expect(ids).toContain(revived.id)
expect(ids).toContain(open.id)
expect(ids).toContain(split.id)
})
})
@@ -29,6 +29,10 @@ import {
shouldReplaceTerminalTab
} from './terminal-surfaces'
import { buildMirroredTerminalTabs } from './terminal-build'
import {
collectHostRetiredTerminals,
isTerminalRowRetiredByHost
} from './host-retired-terminal-rows'
import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority'
export function prepareWebSessionTabsSnapshotBase(
@@ -137,6 +141,7 @@ export function prepareWebSessionTabsSnapshotBase(
}
}
const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys())
const hostRetiredTerminals = collectHostRetiredTerminals(snapshot, environmentId)
const retainedTerminalTabs = reconcilesNonAgentTabs
? currentTerminalTabs.filter(
(tab) =>
@@ -148,6 +153,11 @@ export function prepareWebSessionTabsSnapshotBase(
nextMirroredTerminalIds,
exactProvisionalHandoffs,
state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId
) &&
!isTerminalRowRetiredByHost(
tab,
state.terminalLayoutsByTabId[tab.id],
hostRetiredTerminals
)
)
: currentTerminalTabs
@@ -0,0 +1,63 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import { terminalLayoutLeafIds } from '../web-session-terminal-orphan-recovery-surface-index'
import { isMirroredTerminalSurfaceId } from './terminal-surfaces'
type RetiredSurface = NonNullable<RuntimeMobileSessionTabsResult['retiredTerminalSurfaces']>[number]
export type HostRetiredTerminals = {
ptyIds: ReadonlySet<string>
surfaces: readonly RetiredSurface[]
}
/** The frame's close records, minus any whose handle the host publishes again (handles survive a PTY's re-incarnation). */
export function collectHostRetiredTerminals(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string
): HostRetiredTerminals | null {
if (!snapshot.retiredTerminalSurfaces?.length) {
return null
}
const liveHandles = new Set(
snapshot.tabs.flatMap((tab) =>
tab.type === 'terminal' && typeof tab.terminal === 'string' ? [tab.terminal] : []
)
)
const surfaces = snapshot.retiredTerminalSurfaces.filter(
(surface) => !liveHandles.has(surface.terminal)
)
if (surfaces.length === 0) {
return null
}
return {
ptyIds: new Set(
surfaces.map((surface) => toRemoteRuntimePtyId(surface.terminal, environmentId))
),
surfaces
}
}
/** A row the mirror keeps is the host's to retire once its close records cover every pane the row holds (#11800). */
export function isTerminalRowRetiredByHost(
tab: TerminalTab,
layout: TerminalLayoutSnapshot | undefined,
retired: HostRetiredTerminals | null
): boolean {
if (!retired || isMirroredTerminalSurfaceId(tab.id)) {
return false
}
// A bound pane is identified by its PTY; an unbound one only by the host's tab and leaf ids.
const isRetired = (leafId: string | null, ptyId: string | null | undefined): boolean =>
ptyId
? retired.ptyIds.has(ptyId)
: retired.surfaces.some(
(surface) =>
surface.parentTabId === tab.id && (leafId === null || surface.leafId === leafId)
)
const leaves = terminalLayoutLeafIds(layout)
if (leaves.length === 0) {
return isRetired(null, tab.ptyId)
}
return leaves.every(({ leafId }) => isRetired(leafId, layout?.ptyIdsByLeafId?.[leafId]))
}