mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
No empty extra terminal tab from an old saved terminal record (dark, layout-owner build only) (#31045)
* fix(layout): main sweeps a stored terminal row the window would not reattach, so no empty extra tab shows (dark, layout-owner build only) A stored terminal row with no tab-bar entry became a tab in main's layout even when nothing would reattach it, so with main owning the layout the window showed an empty extra terminal tab that today's build never shows. Today's window drops such a row at startup: it keeps one only when its startup reconnect plan names a terminal for it (the row's own terminal in a workspace live at shutdown and not on SSH, where that id is only a wake hint; an SSH relay session id; or a split row's own pane), and removes the rest with their pane layouts. With the switch on, main's Store load now applies that same sweep once, beside the existing tab-level binding repair, so main's layout, frames and saves never hold the row. Sleeping agent records stay, as they do in today's window. A selection naming a swept row moves as the window's sweep moves it. The window-parity tests now restore with today's window as the oracle and load as main does with the switch on (its startup sweep, then the Loader), so they hold under either build; the ghost-tab entry is removed and each kept and dropped shape has a profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(layout): the startup sweep keeps an SSH row whose own terminal the window may reattach (dark, layout-owner build only) The window ignores an SSH row's own terminal id only when it finds the worktree listed; an SSH folder workspace or an unlisted SSH worktree reattaches by that id. Main cannot see the listing at load, so the sweep keeps such a row rather than risk deleting a terminal the window keeps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(layout): the startup sweep keeps a row any partition's shutdown list names live (dark, layout-owner build only) The window reads one shutdown list merged from every partition, so a stale list in one partition can mark another's workspace live. The sweep now keeps a row live by its own partition's rule or any partition's list, so it never drops a row the window keeps. A session from before the tab bar is swept as the window sweeps it (parity profile added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: E2E Test <e2e@test.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
E2E Test
parent
54f1417af7
commit
764e09bf2b
@@ -19,7 +19,10 @@ import {
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { dropInvalidWorkspaceLayoutsAtLoad } from '../restoring-sessions/invalid-workspace-layout-purge'
|
||||
import { bindPersistedTabTerminalPanes } from '../restoring-sessions/tab-terminal-pane-binding-repair'
|
||||
import {
|
||||
bindPersistedTabTerminalPanes,
|
||||
sweepPersistedUnattachedTerminalRows
|
||||
} from '../restoring-sessions/tab-terminal-pane-binding-repair'
|
||||
import { scheduleSave } from './write-scheduling'
|
||||
import {
|
||||
migrateLegacyOpenCodeGoApiKey,
|
||||
@@ -129,7 +132,7 @@ export class Store {
|
||||
const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
dropInvalidWorkspaceLayoutsAtLoad(this, this.state)
|
||||
this.state = bindPersistedTabTerminalPanes(this.state)
|
||||
this.state = bindPersistedTabTerminalPanes(sweepPersistedUnattachedTerminalRows(this.state))
|
||||
this.runtime.state = this.state
|
||||
}
|
||||
// Imported snapshots cannot own the live hook server or write their source file.
|
||||
|
||||
@@ -8,14 +8,10 @@ import {
|
||||
readCanonicalPtyOwners,
|
||||
resolveCanonicalPtyClaim
|
||||
} from '../../../shared/workspace-layout/legacy-terminal-row-claims'
|
||||
|
||||
/** The reconnect plan's rule (workspace-terminal-reconnect-plan.ts): the shutdown list, else any row with a terminal. */
|
||||
function liveAtShutdown(session: WorkspaceSessionState): (key: string) => boolean {
|
||||
const listed = session.activeWorktreeIdsOnShutdown
|
||||
return listed
|
||||
? (key) => listed.includes(key)
|
||||
: (key) => (session.tabsByWorktree[key] ?? []).some((row) => row.ptyId)
|
||||
}
|
||||
import {
|
||||
liveAtShutdown,
|
||||
sweepUnattachedTerminalRows
|
||||
} from '../../../shared/workspace-layout/unattached-terminal-row-sweep'
|
||||
|
||||
/** Terminals a pane in the layout's tree binds; a binding a removed pane left behind holds nothing. */
|
||||
function mountedPtyIds(layout: TerminalLayoutSnapshot): string[] {
|
||||
@@ -74,15 +70,16 @@ export function bindTabTerminalPanesInSession(
|
||||
return layouts ? { ...session, terminalLayoutsByTabId: layouts } : session
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-only repair for layout-owner builds: main's layout holds these terminals from the start, so
|
||||
* its frames and saves keep them before the pane mounts. A paired server's partition is the
|
||||
* server's and passes through.
|
||||
*/
|
||||
export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedState {
|
||||
type SessionRepair = (
|
||||
session: WorkspaceSessionState,
|
||||
isSshWorkspace: (key: string) => boolean
|
||||
) => WorkspaceSessionState
|
||||
|
||||
/** Repairs every partition this desktop restores; a paired server's is the server's and passes through. */
|
||||
function repairRestoredSessions(state: PersistedState, repair: SessionRepair): PersistedState {
|
||||
const sshRepoIds = new Set(state.repos.filter((repo) => repo.connectionId).map((repo) => repo.id))
|
||||
const sshRepoWorkspace = (key: string): boolean => sshRepoIds.has(getRepoIdFromWorktreeId(key))
|
||||
const workspaceSession = bindTabTerminalPanesInSession(state.workspaceSession, sshRepoWorkspace)
|
||||
const workspaceSession = repair(state.workspaceSession, sshRepoWorkspace)
|
||||
let hostSessionsChanged = false
|
||||
const workspaceSessionsByHostId = state.workspaceSessionsByHostId
|
||||
? Object.fromEntries(
|
||||
@@ -90,10 +87,7 @@ export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedS
|
||||
const kind = parseExecutionHostId(hostId)?.kind
|
||||
const repaired =
|
||||
session && kind !== 'runtime'
|
||||
? bindTabTerminalPanesInSession(
|
||||
session,
|
||||
kind === 'ssh' ? () => true : sshRepoWorkspace
|
||||
)
|
||||
? repair(session, kind === 'ssh' ? () => true : sshRepoWorkspace)
|
||||
: session
|
||||
hostSessionsChanged ||= repaired !== session
|
||||
return [hostId, repaired]
|
||||
@@ -109,3 +103,29 @@ export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedS
|
||||
...(hostSessionsChanged ? { workspaceSessionsByHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-only repair for layout-owner builds: main's layout holds these terminals from the start, so
|
||||
* its frames and saves keep them before the pane mounts.
|
||||
*/
|
||||
export function bindPersistedTabTerminalPanes(state: PersistedState): PersistedState {
|
||||
return repairRestoredSessions(state, bindTabTerminalPanesInSession)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-only repair for layout-owner builds: rows outside the tab bar the window's startup would
|
||||
* sweep go once, before main's layout would draw each as an empty tab.
|
||||
*/
|
||||
export function sweepPersistedUnattachedTerminalRows(state: PersistedState): PersistedState {
|
||||
// Why any partition's list too: a stale list can name another partition's workspace, which the
|
||||
// window's merged list then counts live; keeping on either never sweeps a row the window keeps.
|
||||
const listedAnywhere = new Set(
|
||||
[state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})].flatMap(
|
||||
(session) => session?.activeWorktreeIdsOnShutdown ?? []
|
||||
)
|
||||
)
|
||||
return repairRestoredSessions(state, (session) => {
|
||||
const ownLive = liveAtShutdown(session)
|
||||
return sweepUnattachedTerminalRows(session, (key) => ownLive(key) || listedAnywhere.has(key))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// A stored terminal row with no tab-bar entry: main's load sweeps it once, as the window's startup
|
||||
// does, unless the window's reconnect plan would reattach it; main's layout then never draws an
|
||||
// empty extra terminal tab the window would not show.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { sleepingRecord } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY,
|
||||
leaf
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { sweepPersistedUnattachedTerminalRows } from './tab-terminal-pane-binding-repair'
|
||||
|
||||
const CLI_KEY = 'repo-1::/Users/dev/orca-cli'
|
||||
|
||||
/** tab-a in the tab bar; tab-b only as a row, bound to nothing. */
|
||||
function unattachedRow(): WorkspaceSessionState {
|
||||
const session = addWorkspace(emptySession(), GIT_KEY, [
|
||||
{
|
||||
id: 'g1',
|
||||
tabs: [
|
||||
{ id: 'tab-a', leaves: [[leaf(1), 'pty-a']] },
|
||||
{ id: 'tab-b', leaves: [[leaf(2)]] }
|
||||
]
|
||||
}
|
||||
])
|
||||
session.unifiedTabs![GIT_KEY] = session.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== 'tab-b')
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder = ['tab-a']
|
||||
return session
|
||||
}
|
||||
|
||||
function swept(session: WorkspaceSessionState): WorkspaceSessionState {
|
||||
const state = getDefaultPersistedState('/home/dev')
|
||||
state.workspaceSession = session
|
||||
return sweepPersistedUnattachedTerminalRows(state).workspaceSession
|
||||
}
|
||||
|
||||
const rowIds = (session: WorkspaceSessionState, key = GIT_KEY): string[] =>
|
||||
(session.tabsByWorktree[key] ?? []).map((row) => row.id)
|
||||
|
||||
describe('load sweep: a terminal row outside the tab bar', () => {
|
||||
it('goes with its pane layout when nothing would reattach it', () => {
|
||||
const session = swept(unattachedRow())
|
||||
expect(rowIds(session)).toEqual(['tab-a'])
|
||||
expect(session.terminalLayoutsByTabId['tab-b']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('goes when only a pane binds a terminal, as the window does', () => {
|
||||
const session = unattachedRow()
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = { [leaf(2)]: 'pty-b' }
|
||||
expect(rowIds(swept(session))).toEqual(['tab-a'])
|
||||
})
|
||||
|
||||
it('goes when its workspace was not live at shutdown', () => {
|
||||
const session = unattachedRow()
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b'
|
||||
session.activeWorktreeIdsOnShutdown = []
|
||||
expect(rowIds(swept(session))).toEqual(['tab-a'])
|
||||
})
|
||||
|
||||
it('leaves its sleeping agent record in place, as the window does', () => {
|
||||
const session = unattachedRow()
|
||||
const paneKey = `tab-b:${leaf(2)}`
|
||||
session.sleepingAgentSessionsByPaneKey = {
|
||||
[paneKey]: sleepingRecord(GIT_KEY, 'tab-b', leaf(2))
|
||||
}
|
||||
const after = swept(session)
|
||||
expect(rowIds(after)).toEqual(['tab-a'])
|
||||
expect(Object.keys(after.sleepingAgentSessionsByPaneKey ?? {})).toEqual([paneKey])
|
||||
})
|
||||
|
||||
it('moves selections off it: the workspace to its first kept row, the global one to none', () => {
|
||||
const session = unattachedRow()
|
||||
session.activeTabId = 'tab-b'
|
||||
session.activeTabIdByWorktree = { [GIT_KEY]: 'tab-b' }
|
||||
const after = swept(session)
|
||||
expect(after.activeTabIdByWorktree).toEqual({ [GIT_KEY]: 'tab-a' })
|
||||
expect(after.activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('stays when its own terminal would reattach', () => {
|
||||
const session = unattachedRow()
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b'
|
||||
expect(swept(session)).toBe(session)
|
||||
})
|
||||
|
||||
it('stays when its terminal is on an SSH relay', () => {
|
||||
const session = unattachedRow()
|
||||
session.remoteSessionIdsByTabId = { 'tab-b': 'relay-b' }
|
||||
expect(swept(session)).toBe(session)
|
||||
})
|
||||
|
||||
it('stays when a split row keeps its own pane’s terminal after the tab bar takes the shared one', () => {
|
||||
const session = unattachedRow()
|
||||
session.terminalLayoutsByTabId['tab-b'] = {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: leaf(2) },
|
||||
second: { type: 'leaf', leafId: leaf(3) }
|
||||
},
|
||||
activeLeafId: leaf(2),
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leaf(2)]: 'pty-a', [leaf(3)]: 'pty-c' }
|
||||
}
|
||||
expect(swept(session)).toBe(session)
|
||||
})
|
||||
|
||||
it('stays when the CLI started it in a workspace the window never opened', () => {
|
||||
const session = unattachedRow()
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b'
|
||||
session.tabsByWorktree[CLI_KEY] = [
|
||||
{
|
||||
...session.tabsByWorktree[GIT_KEY]![1]!,
|
||||
id: 'tab-cli',
|
||||
worktreeId: CLI_KEY,
|
||||
ptyId: 'pty-cli'
|
||||
}
|
||||
]
|
||||
expect(swept(session)).toBe(session)
|
||||
})
|
||||
|
||||
it('sweeps SSH partitions too and passes a paired server’s through', () => {
|
||||
const state = getDefaultPersistedState('/home/dev')
|
||||
const sshHost = toSshExecutionHostId('box')
|
||||
const serverHost = toRuntimeExecutionHostId('env-1')
|
||||
const serverSession = unattachedRow()
|
||||
state.workspaceSessionsByHostId = { [sshHost]: unattachedRow(), [serverHost]: serverSession }
|
||||
const after = sweepPersistedUnattachedTerminalRows(state).workspaceSessionsByHostId!
|
||||
expect(rowIds(after[sshHost]!)).toEqual(['tab-a'])
|
||||
expect(after[serverHost]).toBe(serverSession)
|
||||
})
|
||||
|
||||
it('stays when it is an SSH workspace’s and only its own terminal id names one, as the window may reattach it', () => {
|
||||
const state = getDefaultPersistedState('/home/dev')
|
||||
state.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/Users/dev/orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'box'
|
||||
}
|
||||
]
|
||||
const session = unattachedRow()
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b'
|
||||
state.workspaceSession = session
|
||||
expect(rowIds(sweepPersistedUnattachedTerminalRows(state).workspaceSession)).toEqual([
|
||||
'tab-a',
|
||||
'tab-b'
|
||||
])
|
||||
})
|
||||
|
||||
it('stays when another partition’s stale shutdown list names its workspace, as the merged list does', () => {
|
||||
const state = getDefaultPersistedState('/home/dev')
|
||||
const sshHost = toSshExecutionHostId('box')
|
||||
const local = unattachedRow()
|
||||
local.tabsByWorktree[GIT_KEY]![1]!.ptyId = 'pty-b'
|
||||
local.activeWorktreeIdsOnShutdown = []
|
||||
const remote = emptySession()
|
||||
remote.activeWorktreeIdsOnShutdown = [GIT_KEY]
|
||||
state.workspaceSession = local
|
||||
state.workspaceSessionsByHostId = { [sshHost]: remote }
|
||||
expect(sweepPersistedUnattachedTerminalRows(state).workspaceSession).toBe(local)
|
||||
})
|
||||
|
||||
it('treats a session from before the tab bar as the window does: keeps a live row, sweeps the rest', () => {
|
||||
const session = unattachedRow()
|
||||
delete session.unifiedTabs
|
||||
delete session.tabGroups
|
||||
delete session.tabGroupLayouts
|
||||
const after = swept(session)
|
||||
expect(rowIds(after)).toEqual(['tab-a'])
|
||||
expect(after.terminalLayoutsByTabId['tab-a']).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
import {
|
||||
ACCEPTED_DIFFERENCES,
|
||||
GIT,
|
||||
KNOWN_DIFFERENCES
|
||||
KNOWN_DIFFERENCES,
|
||||
sweptAtStartup
|
||||
} from './startup-window-parity-differences.test-fixture'
|
||||
import {
|
||||
bootWindowFromPartitions,
|
||||
@@ -47,6 +48,13 @@ import {
|
||||
projectStartupWindowView
|
||||
} from './startup-window-view.test-fixture'
|
||||
|
||||
// Main and the frame applier run as in a layout-owner build; the restore they match runs as today's.
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: true }))
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
@@ -153,11 +161,11 @@ const PROFILES: readonly WorkspaceLayoutProfile[] = [
|
||||
{ name: 'groups with no selected tab', hostId: 'local', build: groupsWithoutSelection }
|
||||
]
|
||||
|
||||
/** What main publishes for a stored partition, and what the Loader keeps beside the layout. */
|
||||
/** What main publishes for a stored partition after its startup sweep, and what the Loader keeps beside the layout. */
|
||||
function frameInput(hostId: ExecutionHostId, stored: WorkspaceSessionState): LayoutFrameSliceInput {
|
||||
const { layout, facts, desktopView } = loadWorkspaceLayoutWithoutReport(
|
||||
hostId,
|
||||
stored,
|
||||
sweptAtStartup(hostId, stored),
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
return {
|
||||
@@ -220,16 +228,6 @@ type SliceDifference = {
|
||||
|
||||
/** Slice paths that differ. Entries may only be removed. */
|
||||
const SLICE_DIFFERENCES: readonly SliceDifference[] = [
|
||||
{
|
||||
fixture: 'row without a tab-bar entry or a terminal',
|
||||
cause: 'ghost tab (Loader parity)',
|
||||
paths: [
|
||||
`tabsByWorktree ${GIT} 1`,
|
||||
'terminalLayoutsByTabId tab-b',
|
||||
`unifiedTabsByWorktree ${GIT} 1`,
|
||||
`groupsByWorktree ${GIT} 0 tabOrder 1`
|
||||
]
|
||||
},
|
||||
{
|
||||
fixture: 'legacy surface tombstone on a pane still showing its terminal',
|
||||
cause: 'tombstones (Loader parity)',
|
||||
@@ -251,6 +249,11 @@ const SLICE_DIFFERENCES: readonly SliceDifference[] = [
|
||||
'host stamp: a frame names every tab’s host; an adopted tab-bar entry leaves it absent, which readers take as the workspace’s',
|
||||
paths: [`unifiedTabsByWorktree ${GIT} 1 executionHostId`]
|
||||
},
|
||||
{
|
||||
fixture: 'row without a tab-bar entry, its terminal only on a relay',
|
||||
cause: 'host stamp (above)',
|
||||
paths: [`unifiedTabsByWorktree ${GIT} 1 executionHostId`]
|
||||
},
|
||||
{
|
||||
fixture: 'legacy split row sharing one pane’s terminal with a tab-bar tab',
|
||||
cause: 'host stamp (above)',
|
||||
@@ -270,6 +273,18 @@ const SLICE_DIFFERENCES: readonly SliceDifference[] = [
|
||||
`activeGroupIdByWorktree ${GIT}`,
|
||||
`layoutByWorktree ${GIT} groupId`
|
||||
]
|
||||
},
|
||||
{
|
||||
fixture: 'session from before the tab bar, a row with no terminal',
|
||||
cause: 'root group and host stamp (above), for the one kept tab',
|
||||
paths: [
|
||||
`unifiedTabsByWorktree ${GIT} 0 groupId`,
|
||||
`unifiedTabsByWorktree ${GIT} 0 executionHostId`,
|
||||
`groupsByWorktree ${GIT} 0 id`,
|
||||
`groupsByWorktree ${GIT} 0 recentTabIds`,
|
||||
`activeGroupIdByWorktree ${GIT}`,
|
||||
`layoutByWorktree ${GIT} groupId`
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -278,9 +293,12 @@ async function restoreAndApply(profile: WorkspaceLayoutProfile): Promise<{
|
||||
applied: LayoutFrameSlices
|
||||
}> {
|
||||
const stored = profile.build()
|
||||
layoutOwner.isMain = false
|
||||
const restored = await bootWindowFromPartitions(createTestStore(), [
|
||||
{ hostId: profile.hostId, session: stored }
|
||||
])
|
||||
]).finally(() => {
|
||||
layoutOwner.isMain = true
|
||||
})
|
||||
return { restored, applied: buildLayoutFrameSlices(frameInput(profile.hostId, stored)) }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
// each with the Loader rule that causes it. Shared by every parity test that compares a window
|
||||
// built from the Loader's result with the window restored from disk.
|
||||
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { sweepUnattachedTerminalRows } from '../../../shared/workspace-layout/unattached-terminal-row-sweep'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
export const GIT = 'repo-1::/Users/dev/orca'
|
||||
const terminal = (tabId: string) =>
|
||||
['phone and CLI title', 'panes', 'focused pane', 'reattaches'].map(
|
||||
(field) => `${GIT} terminal ${tabId} ${field}`
|
||||
)
|
||||
|
||||
/** Main's startup sweep in a layout-owner build; a paired server's partition passes through. */
|
||||
export function sweptAtStartup(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState
|
||||
): WorkspaceSessionState {
|
||||
return parseExecutionHostId(hostId)?.kind === 'runtime'
|
||||
? session
|
||||
: sweepUnattachedTerminalRows(session)
|
||||
}
|
||||
|
||||
export type KnownDifference = {
|
||||
fixture: string
|
||||
@@ -17,13 +27,7 @@ export type KnownDifference = {
|
||||
}
|
||||
|
||||
/** Differences to fix. Entries may only be removed: fix the rule, then delete its entry. */
|
||||
export const KNOWN_DIFFERENCES: readonly KnownDifference[] = [
|
||||
{
|
||||
fixture: 'row without a tab-bar entry or a terminal',
|
||||
rule: 'ghost tab: the Loader gives every row a tab-bar entry; the window adopts only rows it can reattach',
|
||||
paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-b`, ...terminal('tab-b')]
|
||||
}
|
||||
]
|
||||
export const KNOWN_DIFFERENCES: readonly KnownDifference[] = []
|
||||
|
||||
export type AcceptedDifference = Omit<KnownDifference, 'paths'> & {
|
||||
reason: string
|
||||
|
||||
@@ -16,7 +16,8 @@ import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import {
|
||||
ACCEPTED_DIFFERENCES,
|
||||
GIT,
|
||||
KNOWN_DIFFERENCES
|
||||
KNOWN_DIFFERENCES,
|
||||
sweptAtStartup
|
||||
} from './startup-window-parity-differences.test-fixture'
|
||||
import {
|
||||
bootWindowFromPartitions,
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
projectStartupWindowView
|
||||
} from './startup-window-view.test-fixture'
|
||||
|
||||
// The window boots as today's (the oracle); main loads as it does once it owns the layout.
|
||||
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
return layoutOwner.isMain
|
||||
}
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
@@ -32,14 +40,19 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
/** The shared Loader's round trip, as main would persist it. */
|
||||
/** Main's startup sweep, then the shared Loader's round trip, as main would persist it. */
|
||||
function normalize(hostId: ExecutionHostId, session: WorkspaceSessionState): WorkspaceSessionState {
|
||||
let next = 0
|
||||
const loaded = loadWorkspaceLayout(hostId, structuredClone(session), {
|
||||
mintId: () => `minted-${++next}`,
|
||||
mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}`
|
||||
})
|
||||
return saveWorkspaceLayout(loaded)
|
||||
layoutOwner.isMain = true
|
||||
try {
|
||||
const loaded = loadWorkspaceLayout(hostId, sweptAtStartup(hostId, structuredClone(session)), {
|
||||
mintId: () => `minted-${++next}`,
|
||||
mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}`
|
||||
})
|
||||
return saveWorkspaceLayout(loaded)
|
||||
} finally {
|
||||
layoutOwner.isMain = false
|
||||
}
|
||||
}
|
||||
|
||||
const PROFILES = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES]
|
||||
|
||||
@@ -120,11 +120,17 @@ async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSession
|
||||
/** Fixtures whose frames differ from the restore, as the frame parity test lists. */
|
||||
const FRAME_DIFFERENCE_FIXTURES = new Set([
|
||||
'row without a tab-bar entry or a terminal',
|
||||
'row without a tab-bar entry or a terminal, selected',
|
||||
'row without a tab-bar entry, its terminal in a workspace slept at shutdown',
|
||||
'row without a tab-bar entry, its terminal only on a relay',
|
||||
'row without a tab-bar entry, with a sleeping agent',
|
||||
'row bound only through its pane, without a tab-bar entry',
|
||||
'legacy surface tombstone on a pane still showing its terminal',
|
||||
'tab-bar label differing from the row title',
|
||||
'row without a tab-bar entry, still bound to a terminal',
|
||||
'legacy split row sharing one pane’s terminal with a tab-bar tab',
|
||||
'session from before the tab bar, with an open file'
|
||||
'session from before the tab bar, with an open file',
|
||||
'session from before the tab bar, a row with no terminal'
|
||||
])
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// The window's startup sweep of stored terminal rows outside the tab bar, read from stored data
|
||||
// alone, so main's load can apply it once before main owns the layout. The window adopts such a
|
||||
// row only when its startup reconnect plan (workspace-terminal-reconnect-plan.ts) names a terminal
|
||||
// for it; the orphan sweep (terminal-orphan-helpers.ts) removes the rest with their pane layouts.
|
||||
|
||||
import { isValidTerminalTabId } from '../terminal-tab-id'
|
||||
import type { TerminalTab } from '../terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../workspace-session-state-types'
|
||||
import { readCanonicalPtyOwners, resolveCanonicalPtyClaim } from './legacy-terminal-row-claims'
|
||||
|
||||
/**
|
||||
* The reconnect plan's rule: the shutdown list, else any row with a terminal. Read per partition,
|
||||
* since each lists only its own workspaces.
|
||||
*/
|
||||
export function liveAtShutdown(session: WorkspaceSessionState): (key: string) => boolean {
|
||||
const listed = session.activeWorktreeIdsOnShutdown
|
||||
return listed
|
||||
? (key) => listed.includes(key)
|
||||
: (key) => (session.tabsByWorktree[key] ?? []).some((row) => row.ptyId)
|
||||
}
|
||||
|
||||
type RowSweep = {
|
||||
/** Rows outside the tab bar the reconnect plan names no terminal for. */
|
||||
unattached: Set<string>
|
||||
/** Rows the window's hydration keeps before its sweep; a selection falls back to the first. */
|
||||
hydrated: TerminalTab[]
|
||||
}
|
||||
|
||||
function sweepRows(
|
||||
session: WorkspaceSessionState,
|
||||
key: string,
|
||||
rows: readonly TerminalTab[],
|
||||
live: boolean
|
||||
): RowSweep {
|
||||
const canonical = readCanonicalPtyOwners(session, key, rows)
|
||||
const unattached = new Set<string>()
|
||||
const hydrated: TerminalTab[] = []
|
||||
for (const row of rows) {
|
||||
// Hydration drops these itself; a subsumed row is the Loader's, following to its twin.
|
||||
if (!isValidTerminalTabId(row.id)) {
|
||||
continue
|
||||
}
|
||||
const claim = resolveCanonicalPtyClaim(session, row, canonical)
|
||||
if (claim.kind === 'subsumed') {
|
||||
continue
|
||||
}
|
||||
hydrated.push(row)
|
||||
if (canonical.tabIds.has(row.id)) {
|
||||
continue
|
||||
}
|
||||
// Why not SSH-aware: the window ignores an SSH row's own id only when it finds the worktree
|
||||
// listed, which main cannot know at load; keeping errs to a ghost, never a lost terminal.
|
||||
const named = [live ? row.ptyId : null, session.remoteSessionIdsByTabId?.[row.id]]
|
||||
const reattaches =
|
||||
named.some((ptyId) => ptyId && !claim.releasedPtyIds.has(ptyId)) || claim.reconnectPtyId
|
||||
if (!reattaches) {
|
||||
unattached.add(row.id)
|
||||
}
|
||||
}
|
||||
return { unattached, hydrated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops each stored terminal row the window's startup would sweep, with its pane layout, and
|
||||
* moves a selection off it as the sweep does. Resume and close records stay, as they do there.
|
||||
*/
|
||||
export function sweepUnattachedTerminalRows(
|
||||
session: WorkspaceSessionState,
|
||||
live: (key: string) => boolean = liveAtShutdown(session)
|
||||
): WorkspaceSessionState {
|
||||
let next: WorkspaceSessionState | null = null
|
||||
for (const [key, rows] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
const { unattached, hydrated } = sweepRows(session, key, rows, live(key))
|
||||
if (unattached.size === 0) {
|
||||
continue
|
||||
}
|
||||
next ??= {
|
||||
...session,
|
||||
tabsByWorktree: { ...session.tabsByWorktree },
|
||||
terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId },
|
||||
...(session.activeTabIdByWorktree
|
||||
? { activeTabIdByWorktree: { ...session.activeTabIdByWorktree } }
|
||||
: {})
|
||||
}
|
||||
next.tabsByWorktree[key] = rows.filter((row) => !unattached.has(row.id))
|
||||
for (const tabId of unattached) {
|
||||
delete next.terminalLayoutsByTabId[tabId]
|
||||
}
|
||||
const selected = next.activeTabIdByWorktree?.[key]
|
||||
if (next.activeTabIdByWorktree && selected && unattached.has(selected)) {
|
||||
const [first] = hydrated
|
||||
.filter((row) => !unattached.has(row.id))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
||||
next.activeTabIdByWorktree[key] = first?.id ?? null
|
||||
}
|
||||
if (next.activeTabId && unattached.has(next.activeTabId)) {
|
||||
next.activeTabId = null
|
||||
}
|
||||
}
|
||||
return next ?? session
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../execution-host'
|
||||
import type { WorkspaceSessionState } from '../workspace-session-state-types'
|
||||
import type { WorkspaceLayoutProfile } from './workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
sleepingRecord,
|
||||
type WorkspaceLayoutProfile
|
||||
} from './workspace-layout-profile.test-fixture'
|
||||
import { addWorkspace, emptySession, GIT_KEY, leaf } from './workspace-layout-session.test-fixture'
|
||||
|
||||
function twoTabs(): WorkspaceSessionState {
|
||||
@@ -39,10 +42,44 @@ export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[]
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {}
|
||||
return session
|
||||
}),
|
||||
// Control for the case above: the window adopts a row it can reattach, as the Loader does.
|
||||
local('row without a tab-bar entry or a terminal, selected', () => {
|
||||
const session = withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {}
|
||||
session.activeTabId = 'tab-b'
|
||||
session.activeTabIdByWorktree = { [GIT_KEY]: 'tab-b' }
|
||||
return session
|
||||
}),
|
||||
local('row without a tab-bar entry, its terminal in a workspace slept at shutdown', () => {
|
||||
const session = withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
session.activeWorktreeIdsOnShutdown = []
|
||||
return session
|
||||
}),
|
||||
// Controls for the cases above: the window adopts a row it can reattach, as the Loader does.
|
||||
local('row without a tab-bar entry, still bound to a terminal', () =>
|
||||
withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
),
|
||||
local('row without a tab-bar entry, its terminal only on a relay', () => {
|
||||
const session = withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {}
|
||||
session.remoteSessionIdsByTabId = { 'tab-b': 'relay-b' }
|
||||
return session
|
||||
}),
|
||||
local('row without a tab-bar entry, with a sleeping agent', () => {
|
||||
const session = withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {}
|
||||
session.sleepingAgentSessionsByPaneKey = {
|
||||
[`tab-b:${leaf(2)}`]: sleepingRecord(GIT_KEY, 'tab-b', leaf(2))
|
||||
}
|
||||
return session
|
||||
}),
|
||||
local('row bound only through its pane, without a tab-bar entry', () => {
|
||||
const session = withoutTabBarEntry(twoTabs(), 'tab-b')
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null
|
||||
return session
|
||||
}),
|
||||
local('legacy row repeating a tab-bar tab’s terminal', () => {
|
||||
const session = twoTabs()
|
||||
session.tabsByWorktree[GIT_KEY]!.push({
|
||||
@@ -140,6 +177,16 @@ export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[]
|
||||
session.activeTabTypeByWorktree = { [GIT_KEY]: 'editor' }
|
||||
return session
|
||||
}),
|
||||
local('session from before the tab bar, a row with no terminal', () => {
|
||||
const session = twoTabs()
|
||||
delete session.unifiedTabs
|
||||
delete session.tabGroups
|
||||
delete session.tabGroupLayouts
|
||||
delete session.activeGroupIdByWorktree
|
||||
session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {}
|
||||
return session
|
||||
}),
|
||||
local('legacy surface tombstone on a pane still showing its terminal', () => {
|
||||
const session = twoTabs()
|
||||
session.terminalSurfaceTombstonesByPaneKey = {
|
||||
|
||||
Reference in New Issue
Block a user