mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): heal duplicate leaf and pty bindings during hydration
Hydration now enforces one leaf id per tab and one tab per PTY id over the normalized layouts, so the STA-7961 persisted pair stops restoring two panes onto one session. Losers keep their row and their pane: a split loser detaches the duplicated leaf, a single-leaf loser only unbinds. The reconnect plan reads the healed layout instead of the tab row, so a surrendered PTY cannot come back through tab.ptyId, while a layout with no bindings map at all still answers from the row because nothing migrates that legacy shape.
This commit is contained in:
@@ -554,7 +554,9 @@ describe('worktree agent activation gate', () => {
|
||||
expect(deps.listSurfaceOwners).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mint a second surface for a PTY recorded only on the tab row', async () => {
|
||||
it('lets the execution host decide for a PTY recorded only on the tab row', async () => {
|
||||
// The row is no longer an ownership tier, so it is not evidence of a surface. Only the host
|
||||
// can prove a live PTY has none, and an authoritative empty census says exactly that.
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)] })
|
||||
const store = deps.getState()
|
||||
@@ -563,7 +565,13 @@ describe('worktree agent activation gate', () => {
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(deps.listSurfaceOwners).toHaveBeenCalled()
|
||||
expect(createTab).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ initialPtyId: livePtyId })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the host-owned surface when the renderer projection lost every binding', async () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Ownership is tab-keyed, so this sweep's "is the PTY already shown?" question no longer has a
|
||||
// worktree in it. A pane filed under another key counts; an orphan layout with no row does not.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import {
|
||||
adoptLiveWorkspacePtySurfaces,
|
||||
type LiveSurfaceAdoptionStore
|
||||
} from './worktree-agent-live-surface-adoption'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const OTHER_WORKTREE_ID = 'repo::/other'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LIVE_PTY_ID = 'repo::/worktree@@live-agent'
|
||||
|
||||
function boundLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: LIVE_PTY_ID }
|
||||
}
|
||||
}
|
||||
|
||||
function row(worktreeId: string): TerminalTab {
|
||||
return {
|
||||
id: 'tab-live',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function store(tabsByWorktree: Record<string, TerminalTab[]>): LiveSurfaceAdoptionStore {
|
||||
return {
|
||||
createTab: vi.fn(() => ({ id: 'tab-minted' })),
|
||||
ptyIdsByTabId: {},
|
||||
setTabLayout: vi.fn(),
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId: { 'tab-live': boundLayout() },
|
||||
updateTabPtyId: vi.fn(),
|
||||
replaceTerminalLayoutPanePtyId: vi.fn()
|
||||
} as unknown as LiveSurfaceAdoptionStore
|
||||
}
|
||||
|
||||
describe('live pty surface adoption across worktree keys', () => {
|
||||
it('treats a pane filed under another worktree key as already surfaced', async () => {
|
||||
// Surfacing one PTY twice is the STA-7961 failure; a foreign key is not a second chance.
|
||||
const state = store({ [OTHER_WORKTREE_ID]: [row(OTHER_WORKTREE_ID)] })
|
||||
const listSurfaceOwners = vi.fn(async () => new Map())
|
||||
|
||||
const result = await adoptLiveWorkspacePtySurfaces(
|
||||
() => state,
|
||||
WORKTREE_ID,
|
||||
[LIVE_PTY_ID],
|
||||
listSurfaceOwners
|
||||
)
|
||||
|
||||
expect(result).toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
expect(listSurfaceOwners).not.toHaveBeenCalled()
|
||||
expect(state.createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not count a layout whose row is gone, since it surfaces nothing', async () => {
|
||||
const state = store({})
|
||||
const listSurfaceOwners = vi.fn(async () => new Map())
|
||||
|
||||
await adoptLiveWorkspacePtySurfaces(() => state, WORKTREE_ID, [LIVE_PTY_ID], listSurfaceOwners)
|
||||
|
||||
expect(listSurfaceOwners).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import type { useAppStore } from '@/store'
|
||||
import { resolveTerminalPtyPaneOwnership } from './terminal-pty-pane-owner'
|
||||
import { listTerminalPtyPaneOwners } from './terminal-pty-pane-owner'
|
||||
import { findTerminalTabRow } from './terminal-reveal-tab-adoption'
|
||||
import type {
|
||||
LiveTerminalSurfaceOwner,
|
||||
LiveTerminalSurfaceOwnerIndex
|
||||
@@ -124,18 +125,24 @@ function adoptHostOwnedSurface(
|
||||
* seeded pane, because failing closed must not also fail silent. `declinedPtyIds` names
|
||||
* the live PTYs the sweep left without one, so a decline is diagnosable and not mute.
|
||||
*/
|
||||
/**
|
||||
* Whether some pane in this renderer already shows the PTY. Ownership is tab-keyed, so a row
|
||||
* filed under any other worktree key still counts — a PTY already surfaced must not be adopted
|
||||
* twice. A layout whose row is gone surfaces nothing, so it counts for neither.
|
||||
*/
|
||||
function isPtyAlreadySurfaced(store: LiveSurfaceAdoptionStore, ptyId: string): boolean {
|
||||
return listTerminalPtyPaneOwners(store, ptyId).some(
|
||||
(owner) => findTerminalTabRow(store, owner.tabId) !== null
|
||||
)
|
||||
}
|
||||
|
||||
export async function adoptLiveWorkspacePtySurfaces(
|
||||
getState: () => LiveSurfaceAdoptionStore,
|
||||
worktreeId: string,
|
||||
livePtyIds: readonly string[],
|
||||
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>
|
||||
): Promise<{ surfaced: boolean; declinedPtyIds: string[] }> {
|
||||
// Why: ptyIdsByTabId holds only panes this renderer mounted, so a tab bound solely in the
|
||||
// persisted layout used to read as unbound — under any worktree key, since a PTY already
|
||||
// surfaced elsewhere must not be adopted a second time.
|
||||
const unbound = livePtyIds.filter(
|
||||
(ptyId) => resolveTerminalPtyPaneOwnership(getState(), ptyId).kind === 'none'
|
||||
)
|
||||
const unbound = livePtyIds.filter((ptyId) => !isPtyAlreadySurfaced(getState(), ptyId))
|
||||
let surfaced = unbound.length < livePtyIds.length
|
||||
const declinedPtyIds: string[] = []
|
||||
if (unbound.length === 0) {
|
||||
@@ -151,7 +158,7 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
for (const ptyId of unbound) {
|
||||
// Why: a pane can mount while the census is in flight, so the pre-RPC
|
||||
// verdict is stale by the time it would authorize a mint.
|
||||
if (resolveTerminalPtyPaneOwnership(getState(), ptyId).kind !== 'none') {
|
||||
if (isPtyAlreadySurfaced(getState(), ptyId)) {
|
||||
surfaced = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -383,8 +383,14 @@ describe('reconnectPersistedTerminals', () => {
|
||||
const s = store.getState()
|
||||
// Why: deferred reattach doesn't call spawn — connectPanePty handles it
|
||||
expect((mockApi.pty as Record<string, unknown>).spawn).not.toHaveBeenCalled()
|
||||
// Why: reconnect restores the tab-level ptyId so getWorktreeStatus() shows active (green dot) before the terminal mounts.
|
||||
expect(s.tabsByWorktree[wt1][0].ptyId).toBe('daemon-session-B')
|
||||
// Why: reconnect restores the tab-level ptyId so getWorktreeStatus() shows active (green dot)
|
||||
// before the terminal mounts. The layout is the binding, so the anchor is the active leaf's
|
||||
// session, not whichever pane the persisted row happened to name.
|
||||
const activeLeafId = s.terminalLayoutsByTabId['tab1'].activeLeafId
|
||||
expect(s.tabsByWorktree[wt1][0].ptyId).toBe(
|
||||
s.terminalLayoutsByTabId['tab1'].ptyIdsByLeafId?.[activeLeafId!]
|
||||
)
|
||||
expect(s.tabsByWorktree[wt1][0].ptyId).toBe('daemon-session-A')
|
||||
// ptyIdsByLeafId preserved for connectPanePty; legacy pane:* leaves reminted to durable UUID leaves at hydration.
|
||||
const layout = s.terminalLayoutsByTabId['tab1']
|
||||
const bindings = layout.ptyIdsByLeafId ?? {}
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// A losing tab has a second route back to the PTY its layout just gave up: its own row `ptyId`,
|
||||
// which reconnect copies into pendingReconnectPtyIdByTabId and the pane then takes as a fallback.
|
||||
// This drives the real hydration entry with the STA-7961 pair to pin that route shut.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
|
||||
vi.mock('@/components/terminal-pane/pty-transport', () => ({
|
||||
registerEagerPtyBuffer: vi.fn(),
|
||||
ensurePtyDispatcher: vi.fn()
|
||||
}))
|
||||
|
||||
const apiProxy = (): unknown =>
|
||||
new Proxy(() => undefined, {
|
||||
get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()),
|
||||
apply: () => Promise.resolve(null)
|
||||
})
|
||||
|
||||
// @ts-expect-error -- mocked browser preload API
|
||||
globalThis.window = { api: apiProxy() }
|
||||
|
||||
import { createTestStore, makeTab, makeWorktree, seedStore } from '../slices/store-test-helpers'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/wt-1'
|
||||
const SPLIT_TAB_ID = 'eba00a9a-17df-4152-8258-42381b48890a'
|
||||
const SINGLE_TAB_ID = '881a9ee2-7143-46c8-98ac-8ffbb9cf4b2c'
|
||||
const SHARED_LEAF_ID = '10cb5648-8a54-41c0-a6a4-ef0028d93599'
|
||||
const OWN_LEAF_ID = 'df8913c9-fd8a-420a-a7d6-17daf0ed30f0'
|
||||
const SHARED_PTY_ID = 'repo1::/wt-1@@289ed0f2'
|
||||
const SPLIT_OWN_PTY_ID = 'repo1::/wt-1@@eaff6e99'
|
||||
const LEGACY_TAB_ID = '4b0f3f05-5e9e-4a2e-9f3a-6f1f3a4b7c21'
|
||||
const LEGACY_LEAF_ID = '7c1a8d2e-2f44-4a7b-93b6-10c6a1f9d0b4'
|
||||
const LEGACY_PTY_ID = 'repo1::/wt-1@@legacy01'
|
||||
|
||||
function baseSession(): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: SINGLE_TAB_ID,
|
||||
activeWorktreeIdsOnShutdown: [WORKTREE_ID],
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
terminalLayoutsByTabId: {}
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted pair from the report: both tabs bind the same leaf to the same PTY. */
|
||||
function duplicateLeafSession(): WorkspaceSessionState {
|
||||
return {
|
||||
...baseSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
makeTab({
|
||||
id: SPLIT_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
ptyId: SPLIT_OWN_PTY_ID,
|
||||
sortOrder: 0,
|
||||
createdAt: 1_789_867_969_623
|
||||
}),
|
||||
makeTab({
|
||||
id: SINGLE_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
ptyId: SHARED_PTY_ID,
|
||||
sortOrder: 1,
|
||||
createdAt: 1_789_867_969_624
|
||||
})
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[SPLIT_TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: OWN_LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SHARED_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SHARED_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [OWN_LEAF_ID]: SPLIT_OWN_PTY_ID, [SHARED_LEAF_ID]: SHARED_PTY_ID }
|
||||
},
|
||||
[SINGLE_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: SHARED_LEAF_ID },
|
||||
activeLeafId: SHARED_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [SHARED_LEAF_ID]: SHARED_PTY_ID }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile written before per-leaf bindings existed: a tree, and no bindings map at all. */
|
||||
function legacyLayoutSession(): WorkspaceSessionState {
|
||||
return {
|
||||
...baseSession(),
|
||||
activeTabId: LEGACY_TAB_ID,
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [makeTab({ id: LEGACY_TAB_ID, worktreeId: WORKTREE_ID, ptyId: LEGACY_PTY_ID })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[LEGACY_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEGACY_LEAF_ID },
|
||||
activeLeafId: LEGACY_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(
|
||||
session: WorkspaceSessionState
|
||||
): ReturnType<ReturnType<typeof createTestStore>['getState']> {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/wt-1' })]
|
||||
}
|
||||
})
|
||||
store.getState().hydrateWorkspaceSession(session)
|
||||
return store.getState()
|
||||
}
|
||||
|
||||
describe('hydrating the STA-7961 duplicate binding', () => {
|
||||
it('leaves the shared pty bound to one tab only', () => {
|
||||
const state = hydrate(duplicateLeafSession())
|
||||
|
||||
expect(state.terminalLayoutsByTabId[SPLIT_TAB_ID]?.ptyIdsByLeafId?.[SHARED_LEAF_ID]).toBe(
|
||||
SHARED_PTY_ID
|
||||
)
|
||||
expect(
|
||||
Object.values(state.terminalLayoutsByTabId[SINGLE_TAB_ID]?.ptyIdsByLeafId ?? {})
|
||||
).not.toContain(SHARED_PTY_ID)
|
||||
})
|
||||
|
||||
it('keeps the losing row from taking the pty back through its tab-level id', () => {
|
||||
const state = hydrate(duplicateLeafSession())
|
||||
|
||||
expect(state.pendingReconnectPtyIdByTabId[SINGLE_TAB_ID]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors the winner to what its healed layout binds, not to its row', () => {
|
||||
// The winner's row still names its other pane's pty; the anchor follows the active leaf,
|
||||
// which is the shared session it just won.
|
||||
const state = hydrate(duplicateLeafSession())
|
||||
|
||||
expect(state.pendingReconnectPtyIdByTabId[SPLIT_TAB_ID]).toBe(SHARED_PTY_ID)
|
||||
expect(state.terminalLayoutsByTabId[SPLIT_TAB_ID]?.ptyIdsByLeafId?.[OWN_LEAF_ID]).toBe(
|
||||
SPLIT_OWN_PTY_ID
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps both rows and the losing tab keeps its pane', () => {
|
||||
const state = hydrate(duplicateLeafSession())
|
||||
|
||||
expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
|
||||
SPLIT_TAB_ID,
|
||||
SINGLE_TAB_ID
|
||||
])
|
||||
expect(state.terminalLayoutsByTabId[SINGLE_TAB_ID]?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: SHARED_LEAF_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('a legacy layout with a tree but no bindings map still reconnects through the row', () => {
|
||||
// Nothing migrates tab.ptyId into ptyIdsByLeafId, so this shape survives on disk and the
|
||||
// row is the only thing that names its session. An absent map, not an empty one, marks it.
|
||||
const state = hydrate(legacyLayoutSession())
|
||||
|
||||
expect(state.terminalLayoutsByTabId[LEGACY_TAB_ID]?.ptyIdsByLeafId).toBeUndefined()
|
||||
expect(state.pendingReconnectPtyIdByTabId[LEGACY_TAB_ID]).toBe(LEGACY_PTY_ID)
|
||||
})
|
||||
})
|
||||
@@ -109,11 +109,26 @@ export function createWorkspaceTerminalHydrationActions(
|
||||
runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId)
|
||||
? session.activeRepoId
|
||||
: null
|
||||
// Why indexed here: the layout plan looks up a tab per persisted layout, and re-flattening
|
||||
// tabsByWorktree per entry is O(tabs x layouts).
|
||||
const allTabs = Object.values(tabsByWorktree).flat()
|
||||
const tabById = buildByIdIndex(allTabs)
|
||||
// Why the layout plan first: it is what heals a duplicated leaf or pty binding, and the
|
||||
// reconnect plan below reads the healed layout to decide what a row may reattach to.
|
||||
const { layoutsByTabId } = buildWorkspaceTerminalLayoutPlan({
|
||||
ownershipTransfersByTabId,
|
||||
ownershipTransferTabIds,
|
||||
releasedPtyIdsByTabId,
|
||||
session,
|
||||
tabById,
|
||||
validTabIds
|
||||
})
|
||||
const {
|
||||
pendingReconnectPtyIdByTabId,
|
||||
pendingReconnectTabByWorktree,
|
||||
pendingReconnectWorktreeIds
|
||||
} = buildWorkspaceTerminalReconnectPlan({
|
||||
layoutsByTabId,
|
||||
reconnectPtyIdByRetainedTabId,
|
||||
releasedPtyIdsByTabId,
|
||||
repos: runtimeSessionPlaceholders.repos,
|
||||
@@ -159,10 +174,6 @@ export function createWorkspaceTerminalHydrationActions(
|
||||
if (activeWorktreeId) {
|
||||
nextEverActivated.add(activeWorktreeId)
|
||||
}
|
||||
// Why indexed: the layout map below looks up a tab per persisted layout, and
|
||||
// re-flattening tabsByWorktree per entry is O(tabs x layouts).
|
||||
const allTabs = Object.values(tabsByWorktree).flat()
|
||||
const tabById = buildByIdIndex(allTabs)
|
||||
const hydrated: WorkspaceHydrationPatch = {
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
@@ -210,14 +221,7 @@ export function createWorkspaceTerminalHydrationActions(
|
||||
worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [],
|
||||
worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1,
|
||||
ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)),
|
||||
terminalLayoutsByTabId: buildWorkspaceTerminalLayoutPlan({
|
||||
ownershipTransfersByTabId,
|
||||
ownershipTransferTabIds,
|
||||
releasedPtyIdsByTabId,
|
||||
session,
|
||||
tabById,
|
||||
validTabIds
|
||||
}),
|
||||
terminalLayoutsByTabId: layoutsByTabId,
|
||||
localOnlyScrollbackByTabId: Object.fromEntries(
|
||||
Object.entries(session.localOnlyScrollbackByTabId ?? {}).filter(([tabId]) =>
|
||||
validTabIds.has(tabId)
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// One leaf id belongs to one tab; one PTY id is bound by one tab. Two tabs holding the same
|
||||
// leaf is what let a reveal mint a second pane over a live session (STA-7961), and the loser
|
||||
// must keep its row and its pane — deleting rows is what #13060 did and #13098 reverted.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDuplicateTerminalLayoutBindings } from './workspace-terminal-layout-duplicate-bindings'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
|
||||
const SPLIT_TAB = 'tab-split'
|
||||
const SINGLE_TAB = 'tab-single'
|
||||
// Why real UUIDs: layout normalization re-mints any leaf id that is not a stable pane id.
|
||||
const SHARED_LEAF = '10cb5648-8a54-41c0-a6a4-ef0028d93599'
|
||||
const OTHER_LEAF = 'df8913c9-fd8a-420a-a7d6-17daf0ed30f0'
|
||||
const ELSEWHERE_LEAF = '96cdf7ea-9c83-4ba5-a41b-c425955e6606'
|
||||
const SHARED_PTY = 'wt-1@@shared'
|
||||
|
||||
function tab(id: string, sortOrder: number, createdAt = 1_000 + sortOrder): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
function heal(
|
||||
layoutsByTabId: Record<string, TerminalLayoutSnapshot>,
|
||||
tabs: TerminalTab[],
|
||||
canonicalTabIds: string[] = []
|
||||
): Record<string, TerminalLayoutSnapshot> {
|
||||
return resolveDuplicateTerminalLayoutBindings({
|
||||
canonicalTabIds: new Set(canonicalTabIds),
|
||||
layoutsByTabId,
|
||||
tabById: new Map(tabs.map((row) => [row.id, row]))
|
||||
})
|
||||
}
|
||||
|
||||
function splitLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: OTHER_LEAF },
|
||||
second: { type: 'leaf', leafId: SHARED_LEAF }
|
||||
},
|
||||
activeLeafId: SHARED_LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [OTHER_LEAF]: 'wt-1@@other', [SHARED_LEAF]: SHARED_PTY }
|
||||
}
|
||||
}
|
||||
|
||||
function singleLeafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveDuplicateTerminalLayoutBindings', () => {
|
||||
it('leaves the single-leaf loser its pane and takes only the pty binding', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SPLIT_TAB, 0), tab(SINGLE_TAB, 1)]
|
||||
)
|
||||
|
||||
expect(healed[SPLIT_TAB]!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
expect(healed[SINGLE_TAB]!.root).toEqual({ type: 'leaf', leafId: SHARED_LEAF })
|
||||
expect(healed[SINGLE_TAB]!.activeLeafId).toBe(SHARED_LEAF)
|
||||
})
|
||||
|
||||
it('leaves the loser an explicit, empty bindings map rather than none at all', () => {
|
||||
// An absent map is the legacy shape reconnect still reads `tab.ptyId` for, which would
|
||||
// hand the loser straight back the PTY its layout just surrendered.
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SPLIT_TAB, 0), tab(SINGLE_TAB, 1)]
|
||||
)
|
||||
|
||||
expect(healed[SINGLE_TAB]!.ptyIdsByLeafId).toEqual({})
|
||||
})
|
||||
|
||||
it('removes the duplicated leaf from a split loser and keeps its other panes', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SINGLE_TAB, 0), tab(SPLIT_TAB, 1)]
|
||||
)
|
||||
|
||||
expect(healed[SPLIT_TAB]!.root).toEqual({ type: 'leaf', leafId: OTHER_LEAF })
|
||||
expect(healed[SPLIT_TAB]!.ptyIdsByLeafId).toEqual({ [OTHER_LEAF]: 'wt-1@@other' })
|
||||
expect(healed[SPLIT_TAB]!.activeLeafId).toBe(OTHER_LEAF)
|
||||
expect(healed[SINGLE_TAB]!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
})
|
||||
|
||||
it('unbinds a pty shared under two leaf ids and leaves both panes standing', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(ELSEWHERE_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SINGLE_TAB, 0), tab(SPLIT_TAB, 1)]
|
||||
)
|
||||
|
||||
const owners = Object.entries(healed)
|
||||
.filter(([, layout]) => Object.values(layout.ptyIdsByLeafId ?? {}).includes(SHARED_PTY))
|
||||
.map(([tabId]) => tabId)
|
||||
expect(owners).toEqual([SINGLE_TAB])
|
||||
// The losing pane is real — only its mount collided — so the split keeps both leaves.
|
||||
expect(healed[SPLIT_TAB]!.root).toEqual(splitLayout().root)
|
||||
expect(healed[SPLIT_TAB]!.ptyIdsByLeafId).toEqual({ [OTHER_LEAF]: 'wt-1@@other' })
|
||||
expect(healed[SPLIT_TAB]!.activeLeafId).toBe(OTHER_LEAF)
|
||||
})
|
||||
|
||||
it('lets a canonical unified tab outrank a lower sort order', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SPLIT_TAB, 0), tab(SINGLE_TAB, 1)],
|
||||
[SINGLE_TAB]
|
||||
)
|
||||
|
||||
expect(healed[SINGLE_TAB]!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
expect(healed[SPLIT_TAB]!.root).toEqual({ type: 'leaf', leafId: OTHER_LEAF })
|
||||
})
|
||||
|
||||
it('prefers the older row when sort order ties', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
'tab-new': singleLeafLayout(SHARED_LEAF, SHARED_PTY),
|
||||
'tab-old': singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab('tab-new', 0, 2_000), tab('tab-old', 0, 1_000)]
|
||||
)
|
||||
|
||||
expect(healed['tab-old']!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
expect(healed['tab-new']!.ptyIdsByLeafId).toEqual({})
|
||||
})
|
||||
|
||||
it('breaks an all-equal tie on tab id, whichever order the layouts arrive in', () => {
|
||||
const tabs = [tab('tab-b', 0, 1_000), tab('tab-a', 0, 1_000)]
|
||||
const forward = heal(
|
||||
{
|
||||
'tab-b': singleLeafLayout(SHARED_LEAF, SHARED_PTY),
|
||||
'tab-a': singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
tabs
|
||||
)
|
||||
const reverse = heal(
|
||||
{
|
||||
'tab-a': singleLeafLayout(SHARED_LEAF, SHARED_PTY),
|
||||
'tab-b': singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
tabs
|
||||
)
|
||||
|
||||
for (const healed of [forward, reverse]) {
|
||||
expect(healed['tab-a']!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
expect(healed['tab-b']!.ptyIdsByLeafId).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('ranks a tab id with no surviving row behind one that has one', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
'tab-rowless': singleLeafLayout(SHARED_LEAF, SHARED_PTY),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SINGLE_TAB, 99, 9_000)]
|
||||
)
|
||||
|
||||
expect(healed[SINGLE_TAB]!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
expect(healed['tab-rowless']!.ptyIdsByLeafId).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores a binding whose pane already left the tree', () => {
|
||||
const ghost: TerminalLayoutSnapshot = {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [ELSEWHERE_LEAF]: SHARED_PTY }
|
||||
}
|
||||
const healed = heal({ [SINGLE_TAB]: ghost, [SPLIT_TAB]: splitLayout() }, [
|
||||
tab(SINGLE_TAB, 0),
|
||||
tab(SPLIT_TAB, 1)
|
||||
])
|
||||
|
||||
// The ghost reattaches nothing, so it must not outrank the pane that still mounts the pty.
|
||||
expect(healed[SPLIT_TAB]!.ptyIdsByLeafId?.[SHARED_LEAF]).toBe(SHARED_PTY)
|
||||
})
|
||||
|
||||
it('returns the same object when no tab collides', () => {
|
||||
const layoutsByTabId = {
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout('08d0d524-0d46-410b-ba08-b43c97a2b4e4', 'wt-1@@own')
|
||||
}
|
||||
|
||||
expect(heal(layoutsByTabId, [tab(SPLIT_TAB, 0), tab(SINGLE_TAB, 1)])).toBe(layoutsByTabId)
|
||||
})
|
||||
|
||||
it('keeps every tab id it was given, healed or not', () => {
|
||||
const healed = heal(
|
||||
{
|
||||
[SPLIT_TAB]: splitLayout(),
|
||||
[SINGLE_TAB]: singleLeafLayout(SHARED_LEAF, SHARED_PTY)
|
||||
},
|
||||
[tab(SPLIT_TAB, 0), tab(SINGLE_TAB, 1)]
|
||||
)
|
||||
|
||||
expect(Object.keys(healed).sort()).toEqual([SINGLE_TAB, SPLIT_TAB].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import { detachTerminalLayoutLeaf } from '@/components/terminal-pane/terminal-layout-leaf-detach'
|
||||
import {
|
||||
collectLeafIdsInOrder,
|
||||
resolvePtyBoundActiveLeafId
|
||||
} from '@/components/terminal-pane/terminal-layout-leaf-ids'
|
||||
|
||||
type TerminalLayoutOwnerRanking = {
|
||||
canonicalTabIds: ReadonlySet<string>
|
||||
tabById: ReadonlyMap<string, TerminalTab>
|
||||
}
|
||||
|
||||
/** Terminal rows the unified tab model still lists, across every workspace key. */
|
||||
export function readCanonicalTerminalTabIds(session: WorkspaceSessionState): Set<string> {
|
||||
const tabIds = new Set<string>()
|
||||
for (const tabs of Object.values(session.unifiedTabs ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'terminal') {
|
||||
tabIds.add(tab.entityId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tabIds
|
||||
}
|
||||
|
||||
// Why MAX_SAFE_INTEGER: a tab id with no surviving row ranks behind every row that has one.
|
||||
const MISSING_ROW_RANK = Number.MAX_SAFE_INTEGER
|
||||
|
||||
/** Canonical row, then the order the user put them in, then the older row, then the id. */
|
||||
function compareOwnerTabIds(a: string, b: string, ranking: TerminalLayoutOwnerRanking): number {
|
||||
const canonical =
|
||||
Number(!ranking.canonicalTabIds.has(a)) - Number(!ranking.canonicalTabIds.has(b))
|
||||
if (canonical !== 0) {
|
||||
return canonical
|
||||
}
|
||||
const aTab = ranking.tabById.get(a)
|
||||
const bTab = ranking.tabById.get(b)
|
||||
const sortOrder = (aTab?.sortOrder ?? MISSING_ROW_RANK) - (bTab?.sortOrder ?? MISSING_ROW_RANK)
|
||||
if (sortOrder !== 0) {
|
||||
return sortOrder
|
||||
}
|
||||
const createdAt = (aTab?.createdAt ?? MISSING_ROW_RANK) - (bTab?.createdAt ?? MISSING_ROW_RANK)
|
||||
if (createdAt !== 0) {
|
||||
return createdAt
|
||||
}
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
/** Leaf ids this layout claims as pane identities. A binding off the tree reattaches nothing. */
|
||||
function collectHeldLeafIds(layout: TerminalLayoutSnapshot): string[] {
|
||||
return [...new Set(collectLeafIdsInOrder(layout.root))]
|
||||
}
|
||||
|
||||
/** PTY ids this layout can actually reattach: bound to a leaf its own tree still mounts (#13098). */
|
||||
function collectMountedPtyIds(layout: TerminalLayoutSnapshot): string[] {
|
||||
const leafIdsInTree = new Set(collectLeafIdsInOrder(layout.root))
|
||||
return [
|
||||
...new Set(
|
||||
Object.entries(layout.ptyIdsByLeafId ?? {})
|
||||
.filter(([leafId]) => leafIdsInTree.has(leafId))
|
||||
.map(([, ptyId]) => ptyId)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an explicit map, even when it ends up empty: an absent one reads as a profile written
|
||||
* before leaf bindings existed, and reconnect hands such a row its own `ptyId` straight back.
|
||||
*/
|
||||
function withSurrenderedBindings(
|
||||
layout: TerminalLayoutSnapshot,
|
||||
ptyIdsByLeafId: Record<string, string>
|
||||
): TerminalLayoutSnapshot {
|
||||
return {
|
||||
...layout,
|
||||
ptyIdsByLeafId,
|
||||
activeLeafId: resolvePtyBoundActiveLeafId({
|
||||
root: layout.root,
|
||||
activeLeafId: layout.activeLeafId,
|
||||
ptyIdsByLeafId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function unbindLeafId(layout: TerminalLayoutSnapshot, leafId: string): TerminalLayoutSnapshot {
|
||||
const { [leafId]: _surrendered, ...ptyIdsByLeafId } = layout.ptyIdsByLeafId ?? {}
|
||||
return withSurrenderedBindings(layout, ptyIdsByLeafId)
|
||||
}
|
||||
|
||||
function unbindPtyId(layout: TerminalLayoutSnapshot, ptyId: string): TerminalLayoutSnapshot {
|
||||
return withSurrenderedBindings(
|
||||
layout,
|
||||
Object.fromEntries(
|
||||
Object.entries(layout.ptyIdsByLeafId ?? {}).filter(([, bound]) => bound !== ptyId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A duplicated leaf id is an identity collision, so the losing pane leaves the tree entirely.
|
||||
* A single-leaf tree has nothing to detach into, and a tab with no pane at all is worse than
|
||||
* one that cold-starts a shell, so that loser only gives up the binding.
|
||||
*/
|
||||
function surrenderLeafId(layout: TerminalLayoutSnapshot, leafId: string): TerminalLayoutSnapshot {
|
||||
const detached = detachTerminalLayoutLeaf(layout, leafId)
|
||||
return detached
|
||||
? withSurrenderedBindings(detached.sourceLayout, detached.sourceLayout.ptyIdsByLeafId ?? {})
|
||||
: unbindLeafId(layout, leafId)
|
||||
}
|
||||
|
||||
/** One owner per duplicated key; every loser keeps its row, and nothing is deleted (#13060). */
|
||||
function resolveDuplicateHolders(
|
||||
layoutsByTabId: Record<string, TerminalLayoutSnapshot>,
|
||||
ranking: TerminalLayoutOwnerRanking,
|
||||
readHeldKeys: (layout: TerminalLayoutSnapshot) => string[],
|
||||
surrender: (layout: TerminalLayoutSnapshot, key: string) => TerminalLayoutSnapshot
|
||||
): Record<string, TerminalLayoutSnapshot> {
|
||||
const holderTabIdsByKey = new Map<string, string[]>()
|
||||
for (const [tabId, layout] of Object.entries(layoutsByTabId)) {
|
||||
for (const key of readHeldKeys(layout)) {
|
||||
const holders = holderTabIdsByKey.get(key)
|
||||
if (holders) {
|
||||
holders.push(tabId)
|
||||
} else {
|
||||
holderTabIdsByKey.set(key, [tabId])
|
||||
}
|
||||
}
|
||||
}
|
||||
let healed: Record<string, TerminalLayoutSnapshot> | null = null
|
||||
for (const [key, holderTabIds] of holderTabIdsByKey) {
|
||||
if (holderTabIds.length < 2) {
|
||||
continue
|
||||
}
|
||||
// Why sort over reduce: the id tie-break is deterministic only if every holder is compared.
|
||||
const [, ...losers] = [...holderTabIds].sort((a, b) => compareOwnerTabIds(a, b, ranking))
|
||||
healed ??= { ...layoutsByTabId }
|
||||
for (const tabId of losers) {
|
||||
healed[tabId] = surrender(healed[tabId]!, key)
|
||||
}
|
||||
}
|
||||
return healed ?? layoutsByTabId
|
||||
}
|
||||
|
||||
/**
|
||||
* The hydration invariant: one leaf id belongs to one tab, and one PTY id is bound by one tab.
|
||||
* Leaf ids first, then PTY ids over the healed layouts, so a leaf the first pass surrendered is
|
||||
* not counted twice. Returns the argument itself when nothing collides (STA-7961).
|
||||
*/
|
||||
export function resolveDuplicateTerminalLayoutBindings(args: {
|
||||
canonicalTabIds: ReadonlySet<string>
|
||||
layoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
tabById: ReadonlyMap<string, TerminalTab>
|
||||
}): Record<string, TerminalLayoutSnapshot> {
|
||||
const ranking = { canonicalTabIds: args.canonicalTabIds, tabById: args.tabById }
|
||||
return resolveDuplicateHolders(
|
||||
resolveDuplicateHolders(args.layoutsByTabId, ranking, collectHeldLeafIds, surrenderLeafId),
|
||||
ranking,
|
||||
collectMountedPtyIds,
|
||||
unbindPtyId
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceTerminalLayoutPlan } from './workspace-terminal-layout-plan'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
|
||||
const WORKTREE_ID = 'wt-1'
|
||||
// Ids and shapes copied verbatim from the STA-7961 orca-data.json excerpt.
|
||||
const SHARED_LEAF_ID = '10cb5648-8a54-41c0-a6a4-ef0028d93599'
|
||||
const SHARED_PTY_ID = 'wt-1@@289ed0f2'
|
||||
const SPLIT_TAB_ID = 'eba00a9a-17df-4152-8258-42381b48890a'
|
||||
const SINGLE_TAB_ID = '881a9ee2-7143-46c8-98ac-8ffbb9cf4b2c'
|
||||
|
||||
function tab(id: string, sortOrder: number): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'OpenCode',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt: 1_789_867_969_623 + sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted pair from the report: a nested split and a single leaf, same leaf id and pty. */
|
||||
function duplicateLeafSession(): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: SINGLE_TAB_ID,
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab(SPLIT_TAB_ID, 0), tab(SINGLE_TAB_ID, 1)] },
|
||||
terminalLayoutsByTabId: {
|
||||
[SPLIT_TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'df8913c9-fd8a-420a-a7d6-17daf0ed30f0' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: SHARED_LEAF_ID },
|
||||
second: { type: 'leaf', leafId: '08d0d524-0d46-410b-ba08-b43c97a2b4e4' }
|
||||
}
|
||||
},
|
||||
second: { type: 'leaf', leafId: '96cdf7ea-9c83-4ba5-a41b-c425955e6606' }
|
||||
},
|
||||
activeLeafId: SHARED_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'df8913c9-fd8a-420a-a7d6-17daf0ed30f0': 'wt-1@@eaff6e99',
|
||||
'96cdf7ea-9c83-4ba5-a41b-c425955e6606': 'wt-1@@e905e74a',
|
||||
[SHARED_LEAF_ID]: SHARED_PTY_ID,
|
||||
'08d0d524-0d46-410b-ba08-b43c97a2b4e4': 'wt-1@@8123ec24'
|
||||
}
|
||||
},
|
||||
[SINGLE_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: SHARED_LEAF_ID },
|
||||
activeLeafId: SHARED_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [SHARED_LEAF_ID]: SHARED_PTY_ID }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function planFor(session: WorkspaceSessionState) {
|
||||
const tabs = session.tabsByWorktree[WORKTREE_ID] ?? []
|
||||
return buildWorkspaceTerminalLayoutPlan({
|
||||
ownershipTransfersByTabId: new Map(),
|
||||
ownershipTransferTabIds: null,
|
||||
releasedPtyIdsByTabId: new Map(),
|
||||
session,
|
||||
tabById: new Map(tabs.map((row) => [row.id, row])),
|
||||
validTabIds: new Set(tabs.map((row) => row.id))
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace terminal layout hydration (STA-7961)', () => {
|
||||
it('binds a persisted leaf id to exactly one tab', () => {
|
||||
const { layoutsByTabId } = planFor(duplicateLeafSession())
|
||||
|
||||
const tabsBindingSharedLeaf = Object.entries(layoutsByTabId)
|
||||
.filter(([, layout]) => SHARED_LEAF_ID in (layout.ptyIdsByLeafId ?? {}))
|
||||
.map(([tabId]) => tabId)
|
||||
expect(tabsBindingSharedLeaf).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hands the shared pty to exactly one tab', () => {
|
||||
const { layoutsByTabId } = planFor(duplicateLeafSession())
|
||||
|
||||
const tabsBindingSharedPty = Object.entries(layoutsByTabId)
|
||||
.filter(([, layout]) => Object.values(layout.ptyIdsByLeafId ?? {}).includes(SHARED_PTY_ID))
|
||||
.map(([tabId]) => tabId)
|
||||
expect(tabsBindingSharedPty).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps both layouts, and the loser keeps its pane', () => {
|
||||
const { layoutsByTabId } = planFor(duplicateLeafSession())
|
||||
|
||||
expect(Object.keys(layoutsByTabId).sort()).toEqual([SINGLE_TAB_ID, SPLIT_TAB_ID].sort())
|
||||
expect(layoutsByTabId[SINGLE_TAB_ID]?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: SHARED_LEAF_ID
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership'
|
||||
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
|
||||
import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch'
|
||||
import {
|
||||
readCanonicalTerminalTabIds,
|
||||
resolveDuplicateTerminalLayoutBindings
|
||||
} from './workspace-terminal-layout-duplicate-bindings'
|
||||
|
||||
export type WorkspaceTerminalLayoutPlan = {
|
||||
layoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
}
|
||||
|
||||
export function buildWorkspaceTerminalLayoutPlan({
|
||||
ownershipTransfersByTabId,
|
||||
@@ -23,8 +31,8 @@ export function buildWorkspaceTerminalLayoutPlan({
|
||||
session: WorkspaceSessionState
|
||||
tabById: ReadonlyMap<string, TerminalTab>
|
||||
validTabIds: ReadonlySet<string>
|
||||
}): Record<string, TerminalLayoutSnapshot> {
|
||||
return Object.fromEntries(
|
||||
}): WorkspaceTerminalLayoutPlan {
|
||||
const layoutsByTabId = Object.fromEntries(
|
||||
Object.entries(session.terminalLayoutsByTabId)
|
||||
.filter(([tabId]) => validTabIds.has(tabId))
|
||||
.map(([tabId, persisted]) => {
|
||||
@@ -55,4 +63,13 @@ export function buildWorkspaceTerminalLayoutPlan({
|
||||
return [tabId, { ...sanitized, activeLeafId }]
|
||||
})
|
||||
)
|
||||
// Why after per-tab normalization: a duplicated leaf or pty id is only visible once every
|
||||
// layout has been normalized and had its canonical-row releases applied.
|
||||
return {
|
||||
layoutsByTabId: resolveDuplicateTerminalLayoutBindings({
|
||||
canonicalTabIds: readCanonicalTerminalTabIds(session),
|
||||
layoutsByTabId,
|
||||
tabById
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index'
|
||||
import { resolvePrimaryLayoutPtyId } from './terminal-pty-identities'
|
||||
|
||||
export type WorkspaceTerminalReconnectPlan = {
|
||||
pendingReconnectPtyIdByTabId: Record<string, string>
|
||||
@@ -10,6 +12,7 @@ export type WorkspaceTerminalReconnectPlan = {
|
||||
}
|
||||
|
||||
export function buildWorkspaceTerminalReconnectPlan({
|
||||
layoutsByTabId,
|
||||
reconnectPtyIdByRetainedTabId,
|
||||
releasedPtyIdsByTabId,
|
||||
repos,
|
||||
@@ -18,6 +21,7 @@ export function buildWorkspaceTerminalReconnectPlan({
|
||||
validWorktreeIds,
|
||||
worktreesByRepo
|
||||
}: {
|
||||
layoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
reconnectPtyIdByRetainedTabId: ReadonlyMap<string, string>
|
||||
releasedPtyIdsByTabId: ReadonlyMap<string, ReadonlySet<string>>
|
||||
repos: readonly Repo[]
|
||||
@@ -59,12 +63,18 @@ export function buildWorkspaceTerminalReconnectPlan({
|
||||
continue
|
||||
}
|
||||
for (const tab of session.tabsByWorktree[worktreeId] ?? []) {
|
||||
// Why: the layout is the binding, so a row whose layout surrendered its PTY has nothing to
|
||||
// reattach. A layout with no bindings map at all predates leaf bindings; only that shape
|
||||
// still answers from the row.
|
||||
const layout = layoutsByTabId[tab.id]
|
||||
const reconnectPtyId =
|
||||
layout?.ptyIdsByLeafId === undefined ? tab.ptyId : resolvePrimaryLayoutPtyId(layout)
|
||||
if (
|
||||
tab.ptyId &&
|
||||
reconnectPtyId &&
|
||||
validTabIds.has(tab.id) &&
|
||||
!releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId)
|
||||
!releasedPtyIdsByTabId.get(tab.id)?.has(reconnectPtyId)
|
||||
) {
|
||||
pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId
|
||||
pendingReconnectPtyIdByTabId[tab.id] = reconnectPtyId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user