import invariant from "tiny-invariant"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants"; import { getPanelRegistration } from "@/panels/panel-registry"; import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids"; import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/workspace-tabs/identity"; export interface SplitPane { id: string; tabIds: string[]; focusedTabId: string | null; hidden?: boolean; } export interface SplitGroup { id: string; direction: "horizontal" | "vertical"; children: SplitNode[]; sizes: number[]; } export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; group: SplitGroup }; export interface WorkspaceLayout { root: SplitNode; focusedPaneId: string | null; parentTabIdByTabId?: Record; } interface SplitPaneInternal extends SplitPane { tabs: WorkspaceTab[]; } interface SplitGroupInternal extends Omit { children: SplitNodeInternal[]; } type SplitNodeInternal = | { kind: "pane"; pane: SplitPaneInternal } | { kind: "group"; group: SplitGroupInternal }; interface NormalizeSizesInput { sizes: number[]; count: number; } interface ReorderTabsForPaneInput { pane: SplitPaneInternal; tabIds: string[]; } interface UpdateGroupSizesInTreeInput { groupId: string; sizes: number[]; } interface UpdatePaneInTreeInput { paneId: string; updater: (pane: SplitPaneInternal) => SplitPaneInternal; } interface InsertChildIntoGroupInput { index: number; node: SplitNodeInternal; sizes: number[]; } interface DetachTabFromTreeInput { tabId: string; preserveEmptyPaneId?: string | null; } interface DetachTabFromTreeResult { root: SplitNodeInternal; tab: WorkspaceTab | null; sourcePaneId: string | null; } interface InsertTabIntoPaneInput { paneId: string; tab: WorkspaceTab; focusTabId?: string | null; } interface InsertSplitInternalInput { root: SplitNodeInternal; targetPaneId: string; tabId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; } interface InsertSplitInternalResult { root: SplitNodeInternal; newPaneId: string; } /** * Where an open wants its tab, and how hard it wants it there. * * The distinction only shows up when the tab already exists somewhere. A user who * picks Changes from a specific pane's `+` menu is placing it there and expects it * to arrive. A file link in an agent's output is a supporting open with an opinion * about new tabs only — it must never yank a tab out from under the pane the user * deliberately moved it to. */ export type WorkspaceTabPlacement = /** Pane-local affordance: new tabs open here, existing tabs move here. */ | { mode: "pane"; paneId: string } /** Implicit open: new tabs open here, existing tabs stay where the user left them. */ | { mode: "prefer"; paneId: string } /** The user is working in the focused pane, whichever one that is. */ | { mode: "focused" } /** Nobody placed this tab — reconciliation and other opens with no user behind them. */ | { mode: "ambient" }; export const FOCUSED_PANE_PLACEMENT: WorkspaceTabPlacement = { mode: "focused" }; export const AMBIENT_PLACEMENT: WorkspaceTabPlacement = { mode: "ambient" }; interface OpenTabInLayoutInput { layout: WorkspaceLayout; target: WorkspaceTabTarget; now: number; placement: WorkspaceTabPlacement; /** Required so a new caller cannot silently opt out of the side-panel placement rule. */ sidePanelPaneId: string | null; } interface CreateTabInLayoutInput extends OpenTabInLayoutInput { createTabId: () => string; state?: JsonValue; } interface OpenTabInLayoutResult { layout: WorkspaceLayout; tabId: string; } interface RetargetTabInLayoutInput { layout: WorkspaceLayout; tabId: string; target: WorkspaceTabTarget; } interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput { createTabId: () => string; state?: JsonValue; } interface RetargetTabInLayoutResult { layout: WorkspaceLayout; tabId: string; } interface ConvertDraftToAgentInLayoutInput { layout: WorkspaceLayout; tabId: string; agentId: string; } interface ConvertDraftToAgentInLayoutResult { layout: WorkspaceLayout; tabId: string; } interface ReorderFocusedPaneTabsInLayoutInput { layout: WorkspaceLayout; tabIds: string[]; } interface CloseTabInLayoutInput { layout: WorkspaceLayout; tabId: string; preserveEmptyPaneId?: string | null; } interface ClosePaneInLayoutInput { layout: WorkspaceLayout; paneId: string; } interface SplitPaneInLayoutInput { layout: WorkspaceLayout; tabId: string; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; maxTreeDepth: number; } interface SplitPaneInLayoutResult { layout: WorkspaceLayout; paneId: string; } interface SplitPaneEmptyInLayoutInput { layout: WorkspaceLayout; targetPaneId: string; position: "left" | "right" | "top" | "bottom"; createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string; maxTreeDepth: number; } interface MoveTabToPaneInLayoutInput { layout: WorkspaceLayout; tabId: string; toPaneId: string; } interface FocusTabInLayoutInput { layout: WorkspaceLayout; tabId: string; } interface FocusPaneInLayoutInput { layout: WorkspaceLayout; paneId: string; } interface ResizeSplitInLayoutInput { layout: WorkspaceLayout; groupId: string; sizes: number[]; } interface ReorderPaneTabsInLayoutInput { layout: WorkspaceLayout; paneId: string; tabIds: string[]; } export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; sidePanelPaneId: string | null; } export interface WorkspaceTabSnapshot { agentsHydrated: boolean; terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; hasActivePendingDraftCreate?: boolean; } export const DEFAULT_PANE_ID = "main"; /** The pane id is persisted, so it keeps its pre-rename spelling. */ export const SIDE_PANEL_PANE_ID = "explorer"; const DEFAULT_LAYOUT_GROUP_ID = "workspace-root"; function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeTabIds(list: unknown): string[] { if (!Array.isArray(list)) { return []; } const next: string[] = []; const seen = new Set(); for (const value of list) { const tabId = trimNonEmpty(typeof value === "string" ? value : null); if (!tabId || seen.has(tabId)) { continue; } seen.add(tabId); next.push(tabId); } return next; } function createPaneNode(input: { id: string; tabs?: WorkspaceTab[]; focusedTabId?: string | null; hidden?: boolean; }): SplitNodeInternal { const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []); const tabIds = normalizedTabs.map((tab) => tab.tabId); const focusedTabId = tabIds.includes(input.focusedTabId ?? "") ? (input.focusedTabId ?? null) : (tabIds[tabIds.length - 1] ?? null); return { kind: "pane", pane: { id: input.id, tabs: normalizedTabs, tabIds, focusedTabId, ...(input.hidden === true ? { hidden: true } : {}), }, }; } function createGroupNode(input: { id: string; direction: "horizontal" | "vertical"; children: SplitNodeInternal[]; sizes?: number[]; }): SplitNodeInternal { return { kind: "group", group: { id: input.id, direction: input.direction, children: input.children, sizes: normalizeSizes({ sizes: input.sizes ?? input.children.map(() => 1 / Math.max(input.children.length, 1)), count: input.children.length, }), }, }; } function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { if (!value || typeof value !== "object") { return null; } const tab = value as WorkspaceTab; const target = normalizeWorkspaceTabTarget(tab.target); if (!target) { return null; } const tabId = trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target); if (!tabId) { return null; } return { tabId, target, createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), ...(tab.state !== undefined ? { state: tab.state } : {}), }; } function normalizeWorkspaceTabs(input: unknown): WorkspaceTab[] { if (!Array.isArray(input)) { return []; } const next: WorkspaceTab[] = []; const seen = new Set(); for (const value of input) { const tab = normalizeWorkspaceTab(value); if (!tab || seen.has(tab.tabId)) { continue; } seen.add(tab.tabId); next.push(tab); } return next; } function normalizeSizes(input: NormalizeSizesInput): number[] { if (input.count <= 0) { return []; } const raw = input.sizes.slice(0, input.count); while (raw.length < input.count) { raw.push(1); } const sanitized = raw.map((value) => (Number.isFinite(value) && value > 0 ? value : 1)); const total = sanitized.reduce((sum, value) => sum + value, 0); if (total <= 0) { return Array.from({ length: input.count }, () => 1 / input.count); } return sanitized.map((value) => value / total); } export function clampNormalizedSizes(sizes: number[]): number[] { if (sizes.length === 0) { return []; } const normalized = normalizeSizes({ sizes, count: sizes.length }); if (sizes.length === 1) { return [1]; } if (sizes.length * MIN_SPLIT_SIZE > 1) { return Array.from({ length: sizes.length }, () => 1 / sizes.length); } const nextSizes = Array.from({ length: sizes.length }, () => 0); const unlocked = new Set(normalized.map((_, index) => index)); let remainingTotal = 1; while (unlocked.size > 0) { let unlockedWeight = 0; for (const index of unlocked) { unlockedWeight += normalized[index] ?? 0; } if (unlockedWeight <= 0) { const evenShare = remainingTotal / unlocked.size; for (const index of unlocked) { nextSizes[index] = evenShare; } break; } const nextLocked: number[] = []; for (const index of unlocked) { const proposedSize = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; if (proposedSize < MIN_SPLIT_SIZE) { nextLocked.push(index); } } if (nextLocked.length === 0) { for (const index of unlocked) { nextSizes[index] = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; } break; } for (const index of nextLocked) { nextSizes[index] = MIN_SPLIT_SIZE; unlocked.delete(index); remainingTotal -= MIN_SPLIT_SIZE; } } return normalizeSizes({ sizes: nextSizes, count: nextSizes.length }); } function asInternalNode(node: SplitNode): SplitNodeInternal { return node as SplitNodeInternal; } function asInternalLayout(layout: WorkspaceLayout): { root: SplitNodeInternal; focusedPaneId: string | null; } { return layout as { root: SplitNodeInternal; focusedPaneId: string | null }; } function findPanePathById( node: SplitNodeInternal, paneId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return node.pane.id === paneId ? path : null; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findPanePathById(node.group.children[index], paneId, [...path, index]); if (childPath) { return childPath; } } return null; } function findPanePathContainingTab( node: SplitNodeInternal, tabId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return node.pane.tabs.some((tab) => tab.tabId === tabId) ? path : null; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findPanePathContainingTab(node.group.children[index], tabId, [ ...path, index, ]); if (childPath) { return childPath; } } return null; } function findGroupPathById( node: SplitNodeInternal, groupId: string, path: number[] = [], ): number[] | null { if (node.kind === "pane") { return null; } if (node.group.id === groupId) { return path; } for (let index = 0; index < node.group.children.length; index += 1) { const childPath = findGroupPathById(node.group.children[index], groupId, [...path, index]); if (childPath) { return childPath; } } return null; } /** * The group holding the node at `targetPath`, or null when that node is the whole tree. * * Ask `targetPath`, never the parent path: `targetPath.slice(0, -1)` is empty both for "this node * is the root" and for "this node is a direct child of the root group". Treating the second case as * parentless wraps root-level panes in a redundant group on every split, which reparents the * existing pane and remounts its whole subtree. */ function findParentGroup(root: SplitNodeInternal, targetPath: number[]): SplitNodeInternal | null { if (targetPath.length === 0) { return null; } return getNodeAtPath(root, targetPath.slice(0, -1)); } function getNodeAtPath(node: SplitNodeInternal, path: number[]): SplitNodeInternal { let current = node; for (const index of path) { invariant(current.kind === "group", "Expected group while traversing split tree"); current = current.group.children[index]; } return current; } function replaceNodeAtPath( node: SplitNodeInternal, path: number[], updater: (node: SplitNodeInternal) => SplitNodeInternal, ): SplitNodeInternal { if (path.length === 0) { return updater(node); } invariant(node.kind === "group", "Expected group while replacing split tree node"); const [index, ...rest] = path; const nextChildren = node.group.children.map((child, childIndex) => childIndex === index ? replaceNodeAtPath(child, rest, updater) : child, ); return createGroupNode({ id: node.group.id, direction: node.group.direction, children: nextChildren, sizes: node.group.sizes, }); } function insertChildIntoGroup( groupNode: SplitNodeInternal, input: InsertChildIntoGroupInput, ): SplitNodeInternal { invariant(groupNode.kind === "group", "Expected group for split insertion"); const nextChildren = groupNode.group.children.slice(); nextChildren.splice(input.index, 0, input.node); return createGroupNode({ id: groupNode.group.id, direction: groupNode.group.direction, children: nextChildren, sizes: input.sizes, }); } function listPaneIds(node: SplitNodeInternal): string[] { if (node.kind === "pane") { return node.pane.hidden === true ? [] : [node.pane.id]; } const next: string[] = []; for (const child of node.group.children) { next.push(...listPaneIds(child)); } return next; } function findNearestSiblingPaneId(root: SplitNodeInternal, paneId: string): string | null { const path = findPanePathById(root, paneId); if (!path || path.length === 0) { return null; } for (let depth = path.length - 1; depth >= 0; depth -= 1) { const parentPath = path.slice(0, depth); const childIndex = path[depth]; const parentNode = getNodeAtPath(root, parentPath); invariant(parentNode.kind === "group", "Expected parent group for pane lookup"); for (let index = childIndex - 1; index >= 0; index -= 1) { const paneIds = listPaneIds(parentNode.group.children[index]); if (paneIds.length > 0) { return paneIds[paneIds.length - 1] ?? null; } } for (let index = childIndex + 1; index < parentNode.group.children.length; index += 1) { const paneIds = listPaneIds(parentNode.group.children[index]); if (paneIds.length > 0) { return paneIds[0] ?? null; } } } return null; } function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal { const tabs = normalizeWorkspaceTabs(pane.tabs); const tabIds = tabs.map((tab) => tab.tabId); const focusedTabId = tabIds.includes(pane.focusedTabId ?? "") ? pane.focusedTabId : (tabIds[tabIds.length - 1] ?? null); return { id: pane.id, tabs, tabIds, focusedTabId, ...(pane.hidden === true ? { hidden: true } : {}), }; } function normalizePaneNode(rawPane: SplitPaneInternal | undefined): SplitNodeInternal | null { const paneId = trimNonEmpty(rawPane?.id); if (!paneId) { return null; } const tabs = normalizeWorkspaceTabs(rawPane?.tabs); const tabIds = normalizeTabIds(rawPane?.tabIds); const mergedTabs = tabs.length > 0 ? tabs : tabIds.map((tabId) => ({ tabId, target: { kind: "draft", draftId: tabId } as WorkspaceTabTarget, createdAt: Date.now(), })); return createPaneNode({ id: paneId, tabs: mergedTabs, focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null, hidden: rawPane?.hidden === true, }); } function normalizeGroupNode(rawGroup: SplitGroupInternal | undefined): SplitNodeInternal | null { if (!rawGroup) { return null; } const groupId = trimNonEmpty(rawGroup?.id); const direction = rawGroup?.direction; if (!groupId || (direction !== "horizontal" && direction !== "vertical")) { return null; } const children = Array.isArray(rawGroup.children) ? rawGroup.children .map((child) => normalizeNode(child)) .filter((child): child is SplitNodeInternal => child !== null) : []; if (children.length === 0) { return null; } if (children.length === 1) { return children[0] ?? null; } return createGroupNode({ id: groupId, direction, children, sizes: Array.isArray(rawGroup.sizes) ? rawGroup.sizes : [], }); } function normalizeNode(node: unknown): SplitNodeInternal | null { if (!node || typeof node !== "object") { return null; } if ((node as SplitNode).kind === "pane") { return normalizePaneNode((node as { pane?: SplitPaneInternal }).pane); } if ((node as SplitNode).kind === "group") { return normalizeGroupNode((node as { group?: SplitGroupInternal }).group); } return null; } function reorderTabsForPane(input: ReorderTabsForPaneInput): SplitPaneInternal { const nextIds = normalizeTabIds(input.tabIds); const byId = new Map(input.pane.tabs.map((tab) => [tab.tabId, tab])); const reordered: WorkspaceTab[] = []; const seen = new Set(); for (const tabId of nextIds) { const tab = byId.get(tabId); if (!tab || seen.has(tabId)) { continue; } seen.add(tabId); reordered.push(tab); } for (const tab of input.pane.tabs) { if (seen.has(tab.tabId)) { continue; } seen.add(tab.tabId); reordered.push(tab); } return normalizePaneAfterTabChange({ ...input.pane, tabs: reordered, }); } function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInternal { if (path.length === 0) { invariant(root.kind === "pane", "Expected pane at root while removing pane"); return createPaneNode({ id: root.pane.id }); } const parentPath = path.slice(0, -1); const removeIndex = path[path.length - 1]; const parentNode = getNodeAtPath(root, parentPath); invariant(parentNode.kind === "group", "Expected parent group while removing pane"); const nextParentChildren = parentNode.group.children.filter((_, index) => index !== removeIndex); invariant(nextParentChildren.length > 0, "Split tree cannot remove the final pane"); const nextParentNode = nextParentChildren.length === 1 ? nextParentChildren[0] : createGroupNode({ id: parentNode.group.id, direction: parentNode.group.direction, children: nextParentChildren, sizes: parentNode.group.sizes.filter((_, index) => index !== removeIndex), }); return replaceNodeAtPath(root, parentPath, () => nextParentNode); } function detachTabFromTree( root: SplitNodeInternal, input: DetachTabFromTreeInput, ): DetachTabFromTreeResult { const panePath = findPanePathContainingTab(root, input.tabId); if (!panePath) { return { root, tab: null, sourcePaneId: null }; } const paneNode = getNodeAtPath(root, panePath); invariant(paneNode.kind === "pane", "Expected pane while detaching tab"); const tab = paneNode.pane.tabs.find((entry) => entry.tabId === input.tabId) ?? null; if (!tab) { return { root, tab: null, sourcePaneId: paneNode.pane.id }; } const nextPane = normalizePaneAfterTabChange({ ...paneNode.pane, tabs: paneNode.pane.tabs.filter((entry) => entry.tabId !== input.tabId), }); const nextRoot = replaceNodeAtPath(root, panePath, () => ({ kind: "pane", pane: nextPane })); if (nextPane.tabs.length > 0 || nextPane.id === input.preserveEmptyPaneId) { return { root: nextRoot, tab, sourcePaneId: paneNode.pane.id }; } return { root: removePaneByPath(nextRoot, panePath), tab, sourcePaneId: paneNode.pane.id, }; } function insertTabIntoPane( root: SplitNodeInternal, input: InsertTabIntoPaneInput, ): SplitNodeInternal { const panePath = findPanePathById(root, input.paneId); invariant(panePath, `Pane not found: ${input.paneId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while inserting tab"); const existingIndex = node.pane.tabs.findIndex((tab) => tab.tabId === input.tab.tabId); const nextTabs = existingIndex >= 0 ? node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)) : [...node.pane.tabs, input.tab]; return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, tabs: nextTabs, focusedTabId: input.focusTabId ?? input.tab.tabId, }), }; }); } function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string): SplitNodeInternal { const panePath = findPanePathById(root, paneId); invariant(panePath, `Pane not found: ${paneId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while focusing tab"); return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, focusedTabId: tabId, hidden: undefined, }), }; }); } function replaceTabInTree( root: SplitNodeInternal, input: { tabId: string; nextTabId: string; target: WorkspaceTabTarget; state?: JsonValue; }, ): SplitNodeInternal { const panePath = findPanePathContainingTab(root, input.tabId); invariant(panePath, `Tab not found: ${input.tabId}`); return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while replacing tab"); return { kind: "pane", pane: normalizePaneAfterTabChange({ ...node.pane, tabs: node.pane.tabs.map((tab) => { if (tab.tabId !== input.tabId) return tab; return { tabId: input.nextTabId, target: input.target, createdAt: tab.createdAt, ...(input.state !== undefined ? { state: input.state } : {}), }; }), focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId, }), }; }); } function updateGroupSizesInTree( root: SplitNodeInternal, input: UpdateGroupSizesInTreeInput, ): SplitNodeInternal { const groupPath = findGroupPathById(root, input.groupId); if (!groupPath) { return root; } return replaceNodeAtPath(root, groupPath, (node) => { invariant(node.kind === "group", "Expected group while resizing split"); if (input.sizes.length !== node.group.children.length) { return node; } return createGroupNode({ id: node.group.id, direction: node.group.direction, children: node.group.children, sizes: clampNormalizedSizes(input.sizes), }); }); } function updatePaneInTree( root: SplitNodeInternal, input: UpdatePaneInTreeInput, ): SplitNodeInternal { const panePath = findPanePathById(root, input.paneId); if (!panePath) { return root; } return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while updating pane"); return { kind: "pane", pane: normalizePaneAfterTabChange(input.updater(node.pane)), }; }); } function insertSplitInternal(input: InsertSplitInternalInput): InsertSplitInternalResult { const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical"; const insertAfter = input.position === "right" || input.position === "bottom"; const targetPathBeforeDetach = findPanePathById(input.root, input.targetPaneId); invariant(targetPathBeforeDetach, `Target pane not found: ${input.targetPaneId}`); const detached = detachTabFromTree(input.root, { tabId: input.tabId, preserveEmptyPaneId: input.targetPaneId, }); invariant(detached.tab, `Tab not found: ${input.tabId}`); const targetPath = findPanePathById(detached.root, input.targetPaneId); invariant(targetPath, `Target pane not found after detach: ${input.targetPaneId}`); const targetNode = getNodeAtPath(detached.root, targetPath); invariant(targetNode.kind === "pane", "Expected target pane after detach"); const newPaneId = input.createNodeId("pane"); const newPaneNode = createPaneNode({ id: newPaneId, tabs: [detached.tab], focusedTabId: detached.tab.tabId, }); const parentPath = targetPath.slice(0, -1); const targetIndex = targetPath[targetPath.length - 1] ?? 0; const parentNode = findParentGroup(detached.root, targetPath); if (parentNode?.kind === "group" && parentNode.group.direction === direction) { const targetSize = parentNode.group.sizes[targetIndex] ?? 0; const nextSizes = parentNode.group.sizes.slice(); const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; nextSizes.splice(insertIndex, 0, targetSize / 2); nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; return { root: replaceNodeAtPath(detached.root, parentPath, () => insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes, }), ), newPaneId, }; } const newGroup = createGroupNode({ id: input.createNodeId("group"), direction, children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], sizes: [0.5, 0.5], }); return { root: replaceNodeAtPath(detached.root, targetPath, () => newGroup), newPaneId, }; } export function normalizeLayout(layout: unknown): WorkspaceLayout { if (!layout || typeof layout !== "object") { return createDefaultLayout(); } const rawLayout = layout as WorkspaceLayout; const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root); const focusedPaneId = rawLayout.focusedPaneId === null ? null : trimNonEmpty(rawLayout.focusedPaneId); const resolvedFocusedPaneId = focusedPaneId === null ? null : ((focusedPaneId && findPaneById(root, focusedPaneId)?.id) ?? collectAllPanes(root)[0]?.id ?? DEFAULT_PANE_ID); const normalizedLayout = { root, focusedPaneId: resolvedFocusedPaneId, }; const parentTabIdByTabId = normalizeParentTabMap({ raw: rawLayout.parentTabIdByTabId, openTabIds: new Set(collectAllTabs(root).map((tab) => tab.tabId)), }); return parentTabIdByTabId ? { ...normalizedLayout, parentTabIdByTabId } : normalizedLayout; } export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null { if (!paneId) { return null; } const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.id === paneId ? internalRoot.pane : null; } for (const child of internalRoot.group.children) { const pane = findPaneById(child, paneId); if (pane) { return pane; } } return null; } export function findPaneContainingTab(root: SplitNode, tabId: string): SplitPane | null { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.tabs.some((tab) => tab.tabId === tabId) ? internalRoot.pane : null; } for (const child of internalRoot.group.children) { const pane = findPaneContainingTab(child, tabId); if (pane) { return pane; } } return null; } export function getTreeDepth(node: SplitNode): number { const internalNode = asInternalNode(node); if (internalNode.kind === "pane") { return 1; } return 1 + Math.max(...internalNode.group.children.map((child) => getTreeDepth(child))); } export function collectAllTabs(root: SplitNode): WorkspaceTab[] { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.tabs.slice(); } return internalRoot.group.children.flatMap((child) => collectAllTabs(child)); } export function collectAllPanes(root: SplitNode): SplitPane[] { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.hidden === true ? [] : [internalRoot.pane]; } return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); } function isEphemeralTab(tab: WorkspaceTab): boolean { // Commit diff tabs are ephemeral: their SHA may be rebased away before the next // load, so a restored tab could point at a dead commit. return tab.target.kind === "commit_diff"; } function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal { if (node.kind === "pane") { const nextTabs = node.pane.tabs.filter((tab) => !isEphemeralTab(tab)); if (nextTabs.length === node.pane.tabs.length) { return node; } // createPaneNode repoints focusedTabId to a surviving tab (or null) when the // previously focused tab was an ephemeral one that we just removed. return createPaneNode({ id: node.pane.id, tabs: nextTabs, focusedTabId: node.pane.focusedTabId, hidden: node.pane.hidden, }); } return createGroupNode({ id: node.group.id, direction: node.group.direction, children: node.group.children.map((child) => stripEphemeralTabsFromNode(child)), sizes: node.group.sizes, }); } /** * Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed * from each pane. Applied in the layout store's `partialize` so commit diff tabs * are never written to storage — they're dropped on the next reload rather than * restored pointing at a possibly-rebased SHA. Working diff tabs and all other * tab kinds are left intact. Panes (and their ids/structure) are preserved even * when emptied; the parent-tab map is renormalized against the surviving tabs. */ export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout { const internalLayout = asInternalLayout(layout); const nextRoot = stripEphemeralTabsFromNode(internalLayout.root); return withNormalizedParentTabMap({ root: nextRoot, focusedPaneId: internalLayout.focusedPaneId, parentTabIdByTabId: layout.parentTabIdByTabId, }); } export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null { if (!layout) { return null; } const focusedPane = findPaneById(layout.root, layout.focusedPaneId); if (!focusedPane?.focusedTabId || focusedPane.hidden === true) { return null; } const focusedTab = collectAllTabs(layout.root).find( (tab) => tab.tabId === focusedPane.focusedTabId, ); return focusedTab?.target.kind === "browser" ? focusedTab.target.browserId : null; } export function createDefaultLayout(): WorkspaceLayout { return { root: createPaneNode({ id: DEFAULT_PANE_ID }), focusedPaneId: DEFAULT_PANE_ID, }; } /** The desktop companion pane exists before it is first shown. */ export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout { return { root: createGroupNode({ id: DEFAULT_LAYOUT_GROUP_ID, direction: "horizontal", children: [ createPaneNode({ id: DEFAULT_PANE_ID }), createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }), ], sizes: [0.5, 0.5], }), focusedPaneId: DEFAULT_PANE_ID, }; } export function insertSplit( root: SplitNode, targetPaneId: string, tabId: string, position: "left" | "right" | "top" | "bottom", createNodeId: ( prefix: WorkspaceLayoutNodeIdPrefix, ) => string = defaultWorkspaceLayoutIds.createNodeId, ): SplitNode { return insertSplitInternal({ root: asInternalNode(root), targetPaneId, tabId, position, createNodeId, }).root; } export function removePaneFromTree(root: SplitNode, paneId: string): SplitNode { const internalRoot = asInternalNode(root); const panePath = findPanePathById(internalRoot, paneId); if (!panePath) { return root; } return removePaneByPath(internalRoot, panePath); } export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { return detachTabFromTree(asInternalNode(root), { tabId, preserveEmptyPaneId: DEFAULT_PANE_ID, }).root; } // Tab kinds that belong in the main workspace rather than the side panel. Ambient // opens, and preferred opens whose requested pane no longer exists, have nobody // behind the fallback. The side panel can retain focus from an earlier reveal, so // placement must route these back to the main workspace. const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet = new Set([ "agent", "provider_subagent", "terminal", "draft", "browser", "setup", ]); function resolvePlacementPane(input: { layout: { root: SplitNode; focusedPaneId: string | null }; target: WorkspaceTabTarget; placement: WorkspaceTabPlacement; sidePanelPaneId: string | null; }): SplitPane { const requestedPane = input.placement.mode === "pane" || input.placement.mode === "prefer" ? findPaneById(input.layout.root, input.placement.paneId) : null; if (requestedPane) { return requestedPane; } // `collectAllPanes` skips hidden panes, so a focused-but-hidden pane — the side // panel between a reveal and a hide — falls through to a pane the user can see. const focusedCandidate = findPaneById(input.layout.root, input.layout.focusedPaneId); const focusedPane = (focusedCandidate?.hidden === true ? null : focusedCandidate) ?? collectAllPanes(input.layout.root)[0] ?? findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID); invariant(focusedPane, "Workspace layout must always have a pane"); if ( (input.placement.mode !== "ambient" && input.placement.mode !== "prefer") || focusedPane.id !== input.sidePanelPaneId || !SIDE_PANEL_EXCLUDED_TAB_KINDS.has(input.target.kind) ) { return focusedPane; } // `collectAllPanes` skips hidden panes, so the chain falls back to the side panel // pane itself only when it is the sole visible pane. The side panel is the only // pane anything ever hides, so today that means "the side panel is the only pane". const panes = collectAllPanes(input.layout.root); return ( panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ?? panes.find((pane) => pane.id !== focusedPane.id) ?? focusedPane ); } function insertNewTabIntoPane( input: CreateTabInLayoutInput & { focus: boolean }, ): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); const targetPane = resolvePlacementPane({ layout, target: input.target, placement: input.placement, sidePanelPaneId: input.sidePanelPaneId, }); const tabId = input.createTabId(); const nextTab: WorkspaceTab = { tabId, target: input.target, createdAt: input.now, ...(input.state !== undefined ? { state: input.state } : {}), }; const preservedFocusTabId = targetPane.focusedTabId ?? tabId; return { tabId, layout: withNormalizedParentTabMap({ root: insertTabIntoPane(layout.root, { paneId: targetPane.id, tab: nextTab, focusTabId: input.focus ? tabId : preservedFocusTabId, }), focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) { const registration = getPanelRegistration(target.kind); const targetResourceKey = registration?.resourceKey(target as never); const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target); return ( collectAllTabs(root).find( (tab) => tab.target.kind === target.kind && (registration?.resourceKey(tab.target as never) ?? buildDeterministicWorkspaceTabId(tab.target)) === targetIdentity, ) ?? null ); } function updateExistingTabTarget( layout: WorkspaceLayout, tab: WorkspaceTab, target: WorkspaceTabTarget, ): WorkspaceLayout { if (workspaceTabTargetsEqual(tab.target, target)) { return layout; } return withNormalizedParentTabMap({ ...layout, root: replaceTabInTree(asInternalNode(layout.root), { tabId: tab.tabId, nextTabId: tab.tabId, target, state: tab.state, }), }); } export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); const existingTab = findExistingTabForTarget(layout.root, input.target); if (existingTab) { const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target); return { tabId: existingTab.tabId, layout: revealExistingTab({ layout: nextLayout, tabId: existingTab.tabId, placement: input.placement, }), }; } return insertNewTabIntoPane({ ...input, createTabId: () => buildDeterministicWorkspaceTabId(input.target), focus: true, }); } /** Always allocates an independent tab instance. */ export function createTabInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult { return insertNewTabIntoPane({ ...input, focus: true }); } /** Reveals an equivalent target, or creates it when absent. */ export function revealTargetInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult { const existingTab = findExistingTabForTarget(asInternalLayout(input.layout).root, input.target); if (existingTab) { return { tabId: existingTab.tabId, layout: revealExistingTab({ layout: updateExistingTabTarget(input.layout, existingTab, input.target), tabId: existingTab.tabId, placement: input.placement, }), }; } return createTabInLayout({ ...input, createTabId: () => buildDeterministicWorkspaceTabId(input.target), }); } /** * Brings an already-open tab to the user. Only an explicit pane-local placement * relocates it; every other open finds it where the user last put it. */ function revealExistingTab(input: { layout: WorkspaceLayout; tabId: string; placement: WorkspaceTabPlacement; }): WorkspaceLayout { if (input.placement.mode === "pane") { const currentPane = findPaneContainingTab(asInternalNode(input.layout.root), input.tabId); if (currentPane?.id !== input.placement.paneId) { const moved = moveTabToPaneInLayout({ layout: input.layout, tabId: input.tabId, toPaneId: input.placement.paneId, }); if (moved) { return moved; } } } return focusTabInLayout({ layout: input.layout, tabId: input.tabId }) ?? input.layout; } export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); const existingTab = findExistingTabForTarget(layout.root, input.target); if (existingTab) { return { tabId: existingTab.tabId, layout: updateExistingTabTarget(input.layout, existingTab, input.target), }; } return insertNewTabIntoPane({ ...input, createTabId: () => buildDeterministicWorkspaceTabId(input.target), focus: false, }); } export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null { const internalLayout = asInternalLayout(input.layout); const pane = findPaneContainingTab(internalLayout.root, input.tabId); if (!pane) { return null; } const preserveEmptyPaneId = input.preserveEmptyPaneId ?? (pane.id === DEFAULT_PANE_ID || pane.id === SIDE_PANEL_PANE_ID ? pane.id : null); const closeSuccessorTabId = getCloseSuccessorTabId({ pane, tabId: input.tabId, openTabIds: new Set(collectAllTabs(internalLayout.root).map((tab) => tab.tabId)), parentTabIdByTabId: input.layout.parentTabIdByTabId, }); const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id); const nextRoot = detachTabFromTree(internalLayout.root, { tabId: input.tabId, preserveEmptyPaneId, }).root; const parentTabIdByTabId = normalizeParentTabMap({ raw: input.layout.parentTabIdByTabId, openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)), }); const nextFocusedPaneId = getFocusedPaneIdAfterTabClose({ root: nextRoot, focusedPaneId: internalLayout.focusedPaneId, fallbackPaneId, }); const nextLayout = { root: nextRoot, focusedPaneId: nextFocusedPaneId, }; const nextLayoutWithParentMap = parentTabIdByTabId ? { ...nextLayout, parentTabIdByTabId } : nextLayout; if (closeSuccessorTabId && findPaneContainingTab(nextRoot, closeSuccessorTabId)) { const focusedLayout = focusTabInLayout({ layout: nextLayoutWithParentMap, tabId: closeSuccessorTabId, }) ?? nextLayoutWithParentMap; return parentTabIdByTabId ? { ...focusedLayout, parentTabIdByTabId } : focusedLayout; } return nextLayoutWithParentMap; } /** * Whether dismissing this pane would do anything — removing it, or hiding it if it * is the side panel. A workspace always has somewhere to look, so the last pane the * user can see stays. * * Ask this *before* tearing down the pane's tabs. `closePaneInLayout` and * `setPaneHiddenInLayout` both refuse the same case, but they refuse at the end, * once the tabs are already gone. This is the same rule stated early enough to act * on, and it is the one both the affordance and the action read so they cannot drift. */ export function canDismissPaneInLayout(layout: WorkspaceLayout, paneId: string): boolean { const pane = findPaneById(layout.root, paneId); if (!pane || pane.hidden === true) { return false; } return collectAllPanes(layout.root).length > 1; } /** * Removes a pane outright, tabs and all. Callers own tab teardown (archiving * agents, killing terminals) before calling this; the layout only forgets them. * * The split tree cannot represent a workspace with nothing in it, so the last * visible pane never goes. `main` and the explorer pane are ordinary panes here * — `closeTabInLayout` keeps them alive when they empty, this is how they leave. */ export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const panePath = findPanePathById(layout.root, input.paneId); if (!panePath) { return null; } const visiblePaneIds = listPaneIds(layout.root); if (visiblePaneIds.length <= 1 && visiblePaneIds.includes(input.paneId)) { return null; } const fallbackPaneId = findNearestSiblingPaneId(layout.root, input.paneId); const nextRoot = removePaneByPath(layout.root, panePath); return withNormalizedParentTabMap({ root: nextRoot, focusedPaneId: getFocusedPaneIdAfterTabClose({ root: nextRoot, focusedPaneId: layout.focusedPaneId, fallbackPaneId, }), parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const pane = findPaneContainingTab(layout.root, input.tabId); if (!pane) { return null; } if ( pane.focusedTabId === input.tabId && layout.focusedPaneId === pane.id && pane.hidden !== true ) { return null; } return withNormalizedParentTabMap({ root: focusTabInPane(layout.root, pane.id, input.tabId), focusedPaneId: pane.id, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function retargetTabInLayout( input: RetargetTabInLayoutInput, ): RetargetTabInLayoutResult | null { const layout = asInternalLayout(input.layout); const pane = findPaneContainingTab(layout.root, input.tabId); if (!pane) { return null; } const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null; if (currentTab && workspaceTabTargetsEqual(currentTab.target, input.target)) { return { layout: input.layout, tabId: input.tabId, }; } const existingTargetTab = collectAllTabs(layout.root).find( (tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target), ) ?? null; if (existingTargetTab) { const nextLayout = closeTabInLayout({ layout: input.layout, tabId: input.tabId, }) ?? input.layout; return { layout: focusTabInLayout({ layout: nextLayout, tabId: existingTargetTab.tabId, }) ?? nextLayout, tabId: existingTargetTab.tabId, }; } const nextTabId = currentTab?.target.kind === "draft" ? input.tabId : buildDeterministicWorkspaceTabId(input.target); return { // Preserve draft-origin tab ids so draft->entity transitions keep the same // React key during the first render. Non-draft retargets must take the new // target identity immediately so local tab state cannot masquerade as the // previous agent/terminal/file. tabId: nextTabId, layout: withNormalizedParentTabMap({ root: replaceTabInTree(layout.root, { tabId: input.tabId, nextTabId, target: input.target, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } /** Replaces one pane-local slot and never claims an equivalent tab elsewhere. */ export function replaceTabTargetInLayout( input: ReplaceTabTargetInLayoutInput, ): RetargetTabInLayoutResult | null { const layout = asInternalLayout(input.layout); if (!findPaneContainingTab(layout.root, input.tabId)) return null; const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null; if (currentTab && workspaceTabTargetsEqual(currentTab.target, input.target)) { if (input.state === undefined) { return { layout: input.layout, tabId: input.tabId }; } return { tabId: input.tabId, layout: withNormalizedParentTabMap({ root: replaceTabInTree(layout.root, { tabId: input.tabId, nextTabId: input.tabId, target: input.target, state: input.state, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } let tabId = input.createTabId(); // File navigation is a retarget of one File tab, not a new panel instance. Keeping its // identity preserves the mounted tab track and the File tab's local presentation state. if ( currentTab?.target.kind === "draft" || (currentTab?.target.kind === "file" && input.target.kind === "file") ) { tabId = input.tabId; } else if (input.target.kind === "draft") { tabId = buildDeterministicWorkspaceTabId(input.target); } const parentTabIdByTabId = transferReplacedTabParent({ parentTabIdByTabId: input.layout.parentTabIdByTabId, replacedTabId: input.tabId, replacementTabId: tabId, }); let state: JsonValue | undefined; if (input.state !== undefined) { state = input.state; } else if (currentTab?.target.kind === input.target.kind) { state = currentTab.state; } return { tabId, layout: withNormalizedParentTabMap({ root: replaceTabInTree(layout.root, { tabId: input.tabId, nextTabId: tabId, target: input.target, state, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId, }), }; } function transferReplacedTabParent(input: { parentTabIdByTabId?: Record; replacedTabId: string; replacementTabId: string; }): Record | undefined { if (!input.parentTabIdByTabId) { return undefined; } const parentTabId = input.parentTabIdByTabId[input.replacedTabId]; const renamed: Record = {}; for (const [childTabId, currentParentTabId] of Object.entries(input.parentTabIdByTabId)) { if (childTabId === input.replacedTabId) { continue; } renamed[childTabId] = currentParentTabId === input.replacedTabId ? input.replacementTabId : currentParentTabId; } if (parentTabId) { renamed[input.replacementTabId] = parentTabId === input.replacedTabId ? input.replacementTabId : parentTabId; } return Object.keys(renamed).length > 0 ? renamed : undefined; } export function setTabStateInLayout(input: { layout: WorkspaceLayout; tabId: string; state: JsonValue | undefined; }): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === input.tabId); if (!tab) return null; return withNormalizedParentTabMap({ root: replaceTabInTree(layout.root, { tabId: tab.tabId, nextTabId: tab.tabId, target: tab.target, state: input.state, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function convertDraftToAgentInLayout( input: ConvertDraftToAgentInLayoutInput, ): ConvertDraftToAgentInLayoutResult | null { const layout = asInternalLayout(input.layout); const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null; if (!currentTab || currentTab.target.kind !== "draft") { return null; } const target: WorkspaceTabTarget = { kind: "agent", agentId: input.agentId, }; const canonicalTabId = buildDeterministicWorkspaceTabId(target); const existingCanonicalTab = collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null; if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) { const nextLayout = closeTabInLayout({ layout: input.layout, tabId: input.tabId, }) ?? input.layout; return { layout: focusTabInLayout({ layout: nextLayout, tabId: canonicalTabId, }) ?? nextLayout, tabId: canonicalTabId, }; } return { tabId: canonicalTabId, layout: withNormalizedParentTabMap({ root: replaceTabInTree(layout.root, { tabId: input.tabId, nextTabId: canonicalTabId, target, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } export function reorderFocusedPaneTabsInLayout( input: ReorderFocusedPaneTabsInLayoutInput, ): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); if (!layout.focusedPaneId || !findPaneById(layout.root, layout.focusedPaneId)) { return null; } return withNormalizedParentTabMap({ root: updatePaneInTree(layout.root, { paneId: layout.focusedPaneId, updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }), }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.targetPaneId)) { return null; } if (!findPaneContainingTab(layout.root, input.tabId)) { return null; } const result = insertSplitInternal({ root: layout.root, targetPaneId: input.targetPaneId, tabId: input.tabId, position: input.position, createNodeId: input.createNodeId, }); if (getTreeDepth(result.root) > input.maxTreeDepth) { return null; } return { paneId: result.newPaneId, layout: withNormalizedParentTabMap({ root: result.root, focusedPaneId: result.newPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } export function splitPaneEmptyInLayout( input: SplitPaneEmptyInLayoutInput, ): SplitPaneInLayoutResult | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.targetPaneId)) { return null; } const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical"; const insertAfter = input.position === "right" || input.position === "bottom"; const targetPath = findPanePathById(layout.root, input.targetPaneId); invariant(targetPath, `Target pane not found: ${input.targetPaneId}`); const targetNode = getNodeAtPath(layout.root, targetPath); invariant(targetNode.kind === "pane", "Expected target pane"); const newPaneId = input.createNodeId("pane"); const newPaneNode = createPaneNode({ id: newPaneId }); const parentPath = targetPath.slice(0, -1); const targetIndex = targetPath[targetPath.length - 1] ?? 0; const parentNode = findParentGroup(layout.root, targetPath); let nextRoot: SplitNodeInternal; if (parentNode?.kind === "group" && parentNode.group.direction === direction) { const targetSize = parentNode.group.sizes[targetIndex] ?? 0; const nextSizes = parentNode.group.sizes.slice(); const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; nextSizes.splice(insertIndex, 0, targetSize / 2); nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; nextRoot = replaceNodeAtPath(layout.root, parentPath, () => insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes }), ); } else { const newGroup = createGroupNode({ id: input.createNodeId("group"), direction, children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], sizes: [0.5, 0.5], }); nextRoot = replaceNodeAtPath(layout.root, targetPath, () => newGroup); } if (getTreeDepth(nextRoot) > input.maxTreeDepth) { return null; } return { paneId: newPaneId, layout: withNormalizedParentTabMap({ root: nextRoot, focusedPaneId: newPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }), }; } export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const sourcePane = findPaneContainingTab(layout.root, input.tabId); const targetPane = findPaneById(layout.root, input.toPaneId); if (!sourcePane || !targetPane || targetPane.hidden === true) { return null; } const detached = detachTabFromTree(layout.root, { tabId: input.tabId, // Dragging a pane's last tab elsewhere collapses that pane — except the side // panel, which the user summons and expects to find again, geometry and all. preserveEmptyPaneId: sourcePane.id === input.toPaneId || sourcePane.id === SIDE_PANEL_PANE_ID ? sourcePane.id : null, }); if (!detached.tab) { return null; } return withNormalizedParentTabMap({ root: insertTabIntoPane(detached.root, { paneId: input.toPaneId, tab: detached.tab, focusTabId: input.tabId, }), focusedPaneId: input.toPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null { const pane = findPaneById(input.layout.root, input.paneId); if (!pane) { return null; } if (input.layout.focusedPaneId === input.paneId && pane.hidden !== true) { return null; } return withNormalizedParentTabMap({ root: pane.hidden === true ? updatePaneInTree(asInternalNode(input.layout.root), { paneId: input.paneId, updater: (currentPane) => ({ ...currentPane, hidden: undefined }), }) : input.layout.root, focusedPaneId: input.paneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function setPaneHiddenInLayout(input: { layout: WorkspaceLayout; paneId: string; hidden: boolean; }): WorkspaceLayout | null { const pane = findPaneById(input.layout.root, input.paneId); const isHidden = pane?.hidden === true; if (!pane || isHidden === input.hidden) { return null; } // A workspace always has somewhere to look. This is the single gate on hiding the // last visible pane — `hideSidePanel` and `closePane` both land here, so neither // needs its own check, and `collectAllPanes` skipping hidden panes is what makes // "the only one left" mean visibly left. if (input.hidden && collectAllPanes(input.layout.root).length === 1) { return null; } const root = updatePaneInTree(asInternalNode(input.layout.root), { paneId: input.paneId, updater: (currentPane) => ({ ...currentPane, hidden: input.hidden ? true : undefined, }), }); const focusedPaneId = input.hidden && input.layout.focusedPaneId === input.paneId ? (collectAllPanes(root)[0]?.id ?? null) : input.layout.focusedPaneId; return withNormalizedParentTabMap({ root, focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout { const layout = asInternalLayout(input.layout); return withNormalizedParentTabMap({ root: updateGroupSizesInTree(layout.root, { groupId: input.groupId, sizes: input.sizes, }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } export function reorderPaneTabsInLayout( input: ReorderPaneTabsInLayoutInput, ): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); if (!findPaneById(layout.root, input.paneId)) { return null; } return withNormalizedParentTabMap({ root: updatePaneInTree(layout.root, { paneId: input.paneId, updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }), }), focusedPaneId: layout.focusedPaneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } function normalizeStringSet(values: Iterable): Set { const next = new Set(); for (const value of values) { const normalized = trimNonEmpty(value); if (normalized) { next.add(normalized); } } return next; } function normalizeParentTabMap(input: { raw: unknown; openTabIds: ReadonlySet; }): Record | undefined { if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) { return undefined; } const next: Record = {}; for (const [rawChildId, rawParentId] of Object.entries(input.raw)) { const childId = trimNonEmpty(rawChildId); const parentId = trimNonEmpty(typeof rawParentId === "string" ? rawParentId : null); if ( !childId || !parentId || childId === parentId || !input.openTabIds.has(childId) || !input.openTabIds.has(parentId) ) { continue; } next[childId] = parentId; } return Object.keys(next).length > 0 ? next : undefined; } function normalizeLayoutParentTabMap(layout: WorkspaceLayout): Record | undefined { return normalizeParentTabMap({ raw: layout.parentTabIdByTabId, openTabIds: new Set(collectAllTabs(layout.root).map((tab) => tab.tabId)), }); } function withNormalizedParentTabMap(layout: WorkspaceLayout): WorkspaceLayout { const parentTabIdByTabId = normalizeLayoutParentTabMap(layout); return parentTabIdByTabId ? { ...layout, parentTabIdByTabId } : { root: layout.root, focusedPaneId: layout.focusedPaneId }; } function getCloseSuccessorTabId(input: { pane: SplitPane; tabId: string; openTabIds: ReadonlySet; parentTabIdByTabId?: Record; }): string | null { if (input.pane.focusedTabId !== input.tabId) { return null; } const tabIndex = input.pane.tabIds.indexOf(input.tabId); const parentTabId = input.parentTabIdByTabId?.[input.tabId] ?? null; if (parentTabId && input.openTabIds.has(parentTabId)) { return parentTabId; } return ( input.pane.tabIds[tabIndex + 1] ?? (tabIndex > 0 ? input.pane.tabIds[tabIndex - 1] : null) ?? null ); } function getFocusedPaneIdAfterTabClose(input: { root: SplitNode; focusedPaneId: string | null; fallbackPaneId: string | null; }): string | null { if (input.focusedPaneId === null) { return null; } return ( findPaneById(input.root, input.focusedPaneId)?.id ?? (input.fallbackPaneId && findPaneById(input.root, input.fallbackPaneId)?.id) ?? collectAllPanes(input.root)[0]?.id ?? DEFAULT_PANE_ID ); } function isEntityTarget( target: WorkspaceTabTarget, ): target is Extract { return target.kind === "agent" || target.kind === "terminal"; } function isAgentTab( tab: WorkspaceTab, ): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } { return tab.target.kind === "agent"; } function isTerminalTab( tab: WorkspaceTab, ): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } { return tab.target.kind === "terminal"; } function openEntityTabWithoutFocusing(input: { layout: WorkspaceLayout; target: WorkspaceTabTarget; sidePanelPaneId: string | null; }): WorkspaceLayout { return insertNewTabIntoPane({ layout: input.layout, target: input.target, now: Date.now(), placement: AMBIENT_PLACEMENT, sidePanelPaneId: input.sidePanelPaneId, createTabId: () => buildDeterministicWorkspaceTabId(input.target), focus: false, }).layout; } interface EntityTabGroup { target: WorkspaceTabTarget; tabs: WorkspaceTab[]; } function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; pendingAgentIds: Set; hiddenAgentIds: Set; knownAgentIds: Set; }): Set { const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; const result = new Set(baseAgentIds); for (const agentId of pinnedAgentIds) { if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { result.add(agentId); } } for (const agentId of hiddenAgentIds) { result.delete(agentId); } return result; } function buildEntityTabGroups(initialTabs: WorkspaceTab[]): Map { const entityGroups = new Map(); for (const tab of initialTabs) { if (!isEntityTarget(tab.target)) { continue; } const canonicalTarget = normalizeWorkspaceTabTarget(tab.target); if (!canonicalTarget) { continue; } const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget); const currentGroup = entityGroups.get(canonicalTabId); if (currentGroup) { currentGroup.tabs.push(tab); continue; } entityGroups.set(canonicalTabId, { target: canonicalTarget, tabs: [tab], }); } return entityGroups; } function collapseStaleEntityTabs(input: { layout: WorkspaceLayout; snapshot: WorkspaceTabSnapshot; visibleAgentIds: Set; knownTerminalIds: Set; }): WorkspaceLayout { const { snapshot, visibleAgentIds, knownTerminalIds } = input; let nextLayout = input.layout; for (const tab of collectAllTabs(nextLayout.root)) { if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) { nextLayout = closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, }) ?? nextLayout; } if ( isTerminalTab(tab) && snapshot.terminalsHydrated && !knownTerminalIds.has(tab.target.terminalId) ) { nextLayout = closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, }) ?? nextLayout; } } return nextLayout; } function addMissingEntityTabs(input: { layout: WorkspaceLayout; autoOpenAgentIds: Set; representedAgentIds: Set; standaloneTerminalIds: Set; hasActivePendingTerminalCreate: boolean; hasActivePendingDraftCreate: boolean; sidePanelPaneId: string | null; }): WorkspaceLayout { const { autoOpenAgentIds, representedAgentIds, standaloneTerminalIds, hasActivePendingTerminalCreate, hasActivePendingDraftCreate, sidePanelPaneId, } = input; let nextLayout = input.layout; const currentEntityTabs = collectAllTabs(nextLayout.root); const currentAgentIds = new Set( currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId), ); const currentTerminalIds = new Set( currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId), ); const sortedAutoOpenAgentIds = [...autoOpenAgentIds].sort(); for (const agentId of sortedAutoOpenAgentIds) { if (currentAgentIds.has(agentId)) { continue; } if (hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) { continue; } nextLayout = openEntityTabWithoutFocusing({ layout: nextLayout, target: { kind: "agent", agentId }, sidePanelPaneId, }); currentAgentIds.add(agentId); } const sortedTerminalIds = [...standaloneTerminalIds].sort(); if (!hasActivePendingTerminalCreate) { for (const terminalId of sortedTerminalIds) { if (currentTerminalIds.has(terminalId)) { continue; } nextLayout = openEntityTabWithoutFocusing({ layout: nextLayout, target: { kind: "terminal", terminalId }, sidePanelPaneId, }); currentTerminalIds.add(terminalId); } } return nextLayout; } export function reconcileWorkspaceTabs( state: WorkspaceTabReconcileState, snapshot: WorkspaceTabSnapshot, ): WorkspaceTabReconcileState { let nextLayout = state.layout; const originalFocusedTabId = findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) : standaloneTerminalIds; const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds, }); const initialTabs = collectAllTabs(nextLayout.root); const representedAgentIds = new Set( initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId), ); const entityGroups = buildEntityTabGroups(initialTabs); for (const [canonicalTabId, group] of entityGroups) { const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null; if (!keeper) { continue; } if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) { reconciledFocusedTabId = keeper.tabId; } if (!workspaceTabTargetsEqual(keeper.target, group.target)) { nextLayout = withNormalizedParentTabMap({ root: replaceTabInTree(asInternalLayout(nextLayout).root, { tabId: keeper.tabId, nextTabId: keeper.tabId, target: group.target, }), focusedPaneId: nextLayout.focusedPaneId, parentTabIdByTabId: nextLayout.parentTabIdByTabId, }); } for (const tab of group.tabs) { if (tab.tabId === keeper.tabId) { continue; } nextLayout = closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, }) ?? nextLayout; } } nextLayout = collapseStaleEntityTabs({ layout: nextLayout, snapshot, visibleAgentIds, knownTerminalIds, }); nextLayout = addMissingEntityTabs({ layout: nextLayout, autoOpenAgentIds: autoOpenSet, representedAgentIds, standaloneTerminalIds, hasActivePendingTerminalCreate: snapshot.hasActivePendingTerminalCreate ?? false, hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false, sidePanelPaneId: state.sidePanelPaneId, }); if (reconciledFocusedTabId) { nextLayout = focusTabInLayout({ layout: nextLayout, tabId: reconciledFocusedTabId, }) ?? nextLayout; } return { ...state, layout: nextLayout, }; }