Files
paseo/packages/app/src/stores/workspace-layout-actions.ts
T
Mohamed BoudraandGitHub a69dca2907
CI / changes (push) Successful in 6m6s
Docker / setup (push) Successful in 14s
Docker / publish (push) Skipped
Docker / build (push) Failing after 10m1s
CI / relay-tests (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / server-tests (ubuntu-latest) (push) Canceled after 0s
CI / server-tests (windows-latest) (push) Canceled after 0s
CI / desktop-tests (ubuntu-latest) (push) Canceled after 0s
CI / desktop-tests (windows-latest) (push) Canceled after 0s
CI / app-tests (push) Canceled after 0s
CI / sdk-tests (push) Canceled after 0s
CI / playwright (shard 1/4) (push) Canceled after 0s
CI / playwright (shard 2/4) (push) Canceled after 0s
CI / playwright (shard 3/4) (push) Canceled after 0s
CI / playwright (shard 4/4) (push) Canceled after 0s
CI / format (push) Canceled after 0s
CI / cli-tests (shard 1/3) (push) Canceled after 0s
CI / cli-tests (shard 2/3) (push) Canceled after 0s
CI / cli-tests (shard 3/3) (push) Canceled after 0s
Expand workspace panel actions and keyboard navigation (#3685)
* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
2026-08-22 14:15:23 +02:00

2303 lines
69 KiB
TypeScript

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<string, string>;
}
interface SplitPaneInternal extends SplitPane {
tabs: WorkspaceTab[];
}
interface SplitGroupInternal extends Omit<SplitGroup, "children"> {
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<string> | null;
pendingAgentIds?: ReadonlySet<string> | null;
hiddenAgentIds?: ReadonlySet<string> | null;
sidePanelPaneId: string | null;
}
export interface WorkspaceTabSnapshot {
agentsHydrated: boolean;
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
knownAgentIds: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
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<string>();
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<string>();
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<string>();
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<WorkspaceTabTarget["kind"]> = 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<string, string>;
replacedTabId: string;
replacementTabId: string;
}): Record<string, string> | undefined {
if (!input.parentTabIdByTabId) {
return undefined;
}
const parentTabId = input.parentTabIdByTabId[input.replacedTabId];
const renamed: Record<string, string> = {};
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<string>): Set<string> {
const next = new Set<string>();
for (const value of values) {
const normalized = trimNonEmpty(value);
if (normalized) {
next.add(normalized);
}
}
return next;
}
function normalizeParentTabMap(input: {
raw: unknown;
openTabIds: ReadonlySet<string>;
}): Record<string, string> | undefined {
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
return undefined;
}
const next: Record<string, string> = {};
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<string, string> | 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<string>;
parentTabIdByTabId?: Record<string, string>;
}): 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<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
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<string>;
pinnedAgentIds: Set<string>;
pendingAgentIds: Set<string>;
hiddenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}): Set<string> {
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<string, EntityTabGroup> {
const entityGroups = new Map<string, EntityTabGroup>();
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<string>;
knownTerminalIds: Set<string>;
}): 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<string>;
representedAgentIds: Set<string>;
standaloneTerminalIds: Set<string>;
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,
};
}