mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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]))
|
||||
}
|
||||
Reference in New Issue
Block a user