mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
* Put sheet tabs on the one sheet stack Sheets are one primitive with one stack. A sheet opened from any sheet stacks on top of it, and closing it uncovers the sheet underneath. The compact workspace's file and subagent sheets were an in-tree Gorhom BottomSheet with their own stacking, so a subagent chosen from the subagents list sheet replaced the list instead of covering it. - SheetHostScope: a subtree hosts the sheets presented inside it, so the sheet tabs draw inside the compact column and read the workspace providers while still joining the app-wide stack. Needs Gorhom's BottomSheetModalInternalProvider, which the patch exports. - IsolatedBottomSheetModal takes enableDismissOnClose; false keeps the content mounted through a close so the unsaved-changes guard can veto it. Its backdrop then closes the sheet rather than dismissing the top modal. The Gorhom patch also keeps a dismissing modal's status through its final onChange(-1), without which such a modal unmounting while presented never left the portal. - SheetTabs (was WorkspaceSheetHost) renders one IsolatedBottomSheetModal per store entry. The store never reorders sheets; a reveal of a covered sheet drops the unmodified sheets above it. - Subagent rows that open a sheet keep the list open (closeOnSelect false); rows that open a tab still close it. - A menu closes when its retained panel goes inactive, instead of hiding and reappearing with the panel on the next tab switch. Co-Authored-By: Claude <noreply@anthropic.com> * Show an agent's model and thinking in its sheet, and fit Open as tab in the header rail A Paseo agent shown in a sheet has no composer, so the facts the composer's model and thinking controls would show now sit in one line under the sheet header, the same row provider subagents already get from the daemon. The line resolves exactly as the controls do (resolveAgentModelSelection), from the session agent and the providers snapshot, and shows wherever the pane host is a sheet. Open as tab becomes the full-screen glyph beside Close, drawn by the shared SheetHeaderIconButton, so the title keeps its width. The tab switcher sheet is titled "Workspace tabs" so it reads as what it lists. Co-Authored-By: Claude <noreply@anthropic.com> * Show mode and context usage in the sheet facts line The line under a sheet's header now carries everything the composer's controls and context meter show: model, thinking, mode, and used/max context tokens with the share, each resolved as its control resolves it. Co-Authored-By: Claude <noreply@anthropic.com> * Re-render subagent rows when the workspace's tabs change A row decides at render time whether choosing it opens a sheet or reveals a tab, and keeps the list open only for a sheet. The hook behind it only subscribed to the layout mode, so a row rendered while its agent was a full tab kept closing the list after that tab was gone: on iOS, promoting a managed child, closing its tab, and reopening it from the list dropped the list under the sheet. Subscribe to the workspace's layout too. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
2274 lines
83 KiB
TypeScript
2274 lines
83 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { useEffect, useState } from "react";
|
|
import { create } from "zustand";
|
|
import { persist } from "zustand/middleware";
|
|
import type { z } from "zod";
|
|
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
|
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
|
import {
|
|
defaultWorkspaceLayoutIds,
|
|
type WorkspaceLayoutIdSource,
|
|
} from "@/stores/workspace-layout-ids";
|
|
import {
|
|
canDismissPaneInLayout,
|
|
clampNormalizedSizes,
|
|
closePaneInLayout,
|
|
closeTabInLayout,
|
|
collectAllPanes,
|
|
collectAllTabs,
|
|
convertDraftToAgentInLayout,
|
|
createTabInLayout,
|
|
createDefaultLayout,
|
|
DEFAULT_PANE_ID,
|
|
AMBIENT_PLACEMENT,
|
|
createWorkspaceLayoutWithExplorerSidebar,
|
|
FOCUSED_PANE_PLACEMENT,
|
|
EXPLORER_SIDEBAR_PANE_ID,
|
|
findPaneById,
|
|
findPaneContainingTab,
|
|
focusPaneInLayout,
|
|
focusTabInLayout,
|
|
getFocusedBrowserId,
|
|
getTreeDepth,
|
|
insertSplit,
|
|
moveTabToPaneInLayout,
|
|
normalizeLayout,
|
|
openTabInLayoutBackground,
|
|
replaceTabTargetInLayout,
|
|
revealTargetInLayout,
|
|
restoreWorkspaceLayout,
|
|
reconcileWorkspaceTabs,
|
|
removePaneFromTree,
|
|
removeTabFromTree,
|
|
reorderFocusedPaneTabsInLayout,
|
|
reorderPaneTabsInLayout,
|
|
setPaneHiddenInLayout,
|
|
setTabStateInLayout,
|
|
selectTabInPaneInLayout,
|
|
splitPaneEmptyInLayout,
|
|
splitWorkspaceRootRightInLayout,
|
|
splitPaneInLayout,
|
|
stripEphemeralTabsFromLayout,
|
|
type SplitGroup,
|
|
type SplitNode,
|
|
type SplitPane,
|
|
type WorkspaceTabPlacement,
|
|
type WorkspaceTabInsertionPosition,
|
|
type WorkspaceTabReconcileState,
|
|
type WorkspaceTabSnapshot,
|
|
type WorkspaceLayout,
|
|
} from "@/stores/workspace-layout-actions";
|
|
import {
|
|
buildDeterministicWorkspaceTabId,
|
|
normalizeWorkspaceTabTarget,
|
|
} from "@/workspace-tabs/identity";
|
|
import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet";
|
|
import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
|
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
|
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
|
|
|
export {
|
|
AMBIENT_PLACEMENT,
|
|
canDismissPaneInLayout,
|
|
collectAllPanes,
|
|
collectAllTabs,
|
|
createDefaultLayout,
|
|
DEFAULT_PANE_ID,
|
|
createWorkspaceLayoutWithExplorerSidebar,
|
|
FOCUSED_PANE_PLACEMENT,
|
|
findPaneById,
|
|
findPaneContainingTab,
|
|
getFocusedBrowserId,
|
|
getTreeDepth,
|
|
insertSplit,
|
|
normalizeLayout,
|
|
removePaneFromTree,
|
|
removeTabFromTree,
|
|
stripEphemeralTabsFromLayout,
|
|
};
|
|
export type {
|
|
SplitGroup,
|
|
SplitNode,
|
|
SplitPane,
|
|
WorkspaceLayout,
|
|
WorkspaceTabPlacement,
|
|
WorkspaceTabReconcileState,
|
|
WorkspaceTabSnapshot,
|
|
};
|
|
|
|
export type WorkspaceTabOpenIntent = "new" | "reveal" | "background";
|
|
export interface OpenWorkspaceTabInput {
|
|
workspaceKey: string;
|
|
target: WorkspaceTabTarget;
|
|
intent: WorkspaceTabOpenIntent;
|
|
/** Keeps an explicitly opened agent visible even when it is archived. */
|
|
pin?: boolean;
|
|
placement?: WorkspaceTabPlacement;
|
|
insertionPosition?: WorkspaceTabInsertionPosition;
|
|
parentTabId?: string;
|
|
state?: JsonValue;
|
|
}
|
|
|
|
/**
|
|
* How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single
|
|
* wide pane (touch tablets, which cannot render splits), or split panes.
|
|
*/
|
|
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
|
|
|
|
/** A tab shown in a sheet over the compact workspace instead of in a pane. */
|
|
export interface SheetTab {
|
|
tab: WorkspaceTab;
|
|
policy: CompactSheetPolicy;
|
|
parentTabId: string | null;
|
|
}
|
|
|
|
interface WorkspaceLayoutStore {
|
|
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
|
layoutMode: WorkspaceLayoutMode;
|
|
/**
|
|
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
|
|
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
|
|
* its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab
|
|
* strip.
|
|
*/
|
|
sheetsByWorkspace: Record<string, readonly SheetTab[]>;
|
|
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
|
explorerSidebarWidthByWorkspace: Record<string, number>;
|
|
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
|
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
|
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
|
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
|
|
sidePaneIdByWorkspace: Record<string, string | null>;
|
|
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
|
|
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
|
/**
|
|
* The one entry point for showing a target. On compact layouts a target whose panel declares
|
|
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
|
|
*/
|
|
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
|
/** Whether `openTab` with this input would show the target in a sheet. */
|
|
opensInSheet: (
|
|
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
|
) => boolean;
|
|
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
|
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
|
|
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
|
|
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
|
autoOpenPullRequestTab: (
|
|
workspaceKey: string,
|
|
resolvePlacement: () => Pick<OpenWorkspaceTabInput, "placement" | "insertionPosition">,
|
|
) => string | null;
|
|
/** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */
|
|
showExplorerSidebar: (workspaceKey: string) => string | null;
|
|
hideExplorerSidebar: (workspaceKey: string) => void;
|
|
/** Returns the ordinary right-side workspace pane, creating it when absent. */
|
|
ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null;
|
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
|
focusTab: (workspaceKey: string, tabId: string) => void;
|
|
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
|
|
replaceTab: (
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
target: WorkspaceTabTarget,
|
|
state?: JsonValue,
|
|
) => string | null;
|
|
setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void;
|
|
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
|
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
|
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
|
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
|
splitPane: (
|
|
workspaceKey: string,
|
|
input: {
|
|
tabId: string;
|
|
targetPaneId: string;
|
|
position: "left" | "right" | "top" | "bottom";
|
|
},
|
|
) => string | null;
|
|
splitPaneEmpty: (
|
|
workspaceKey: string,
|
|
input: {
|
|
targetPaneId: string;
|
|
position: "left" | "right" | "top" | "bottom";
|
|
},
|
|
) => string | null;
|
|
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
|
/**
|
|
* Dismisses the pane along with whatever it still holds. The Explorer hides so the
|
|
* user can bring it back; every ordinary pane is removed. Callers own tab teardown
|
|
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
|
|
*/
|
|
closePane: (workspaceKey: string, paneId: string) => void;
|
|
focusPane: (workspaceKey: string, paneId: string) => void;
|
|
unfocusPane: (workspaceKey: string) => string | null;
|
|
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
|
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
|
resizeExplorerSidebar: (workspaceKey: string, width: number) => void;
|
|
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
|
unpinAgent: (workspaceKey: string, agentId: string) => void;
|
|
hideAgent: (workspaceKey: string, agentId: string) => void;
|
|
unhideAgent: (workspaceKey: string, agentId: string) => void;
|
|
purgeWorkspace: (workspaceKey: string) => void;
|
|
}
|
|
|
|
interface WorkspaceFocusRestorationState {
|
|
restorePaneId: string | null;
|
|
tokens: string[];
|
|
}
|
|
|
|
// The persisted tree includes the Explorer shell; the renderer docks it outside workspace splits.
|
|
// Preserve four user-created split levels beneath that bookkeeping node.
|
|
const MAX_TREE_DEPTH = 5;
|
|
|
|
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
|
|
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
|
|
|
|
function convertLegacyExplorerSidebarRatios(
|
|
ratiosByWorkspace: Record<string, number>,
|
|
): Record<string, number> {
|
|
return Object.fromEntries(
|
|
Object.entries(ratiosByWorkspace).map(([workspaceKey, ratio]) => [
|
|
workspaceKey,
|
|
ratio * LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH,
|
|
]),
|
|
);
|
|
}
|
|
|
|
function replacePaneInNode(node: SplitNode, paneId: string, replacement: SplitPane): SplitNode {
|
|
if (node.kind === "pane") {
|
|
return node.pane.id === paneId ? { kind: "pane", pane: replacement } : node;
|
|
}
|
|
return {
|
|
kind: "group",
|
|
group: {
|
|
...node.group,
|
|
children: node.group.children.map((child) => replacePaneInNode(child, paneId, replacement)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function visiblePane<TPane extends SplitPane>(pane: TPane): Omit<TPane, "hidden"> {
|
|
const { hidden: _hidden, ...visible } = pane;
|
|
return visible;
|
|
}
|
|
|
|
function createExplorerSidebarNode(): SplitNode {
|
|
const layout = createWorkspaceLayoutWithExplorerSidebar();
|
|
const pane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID);
|
|
if (!pane) {
|
|
throw new Error("Default Explorer pane is missing");
|
|
}
|
|
return { kind: "pane", pane };
|
|
}
|
|
|
|
function preserveVersionOneSideTabs(input: {
|
|
layout: WorkspaceLayout;
|
|
legacyExplorerPane: SplitPane;
|
|
rememberedSidePane: SplitPane | null;
|
|
tabs: WorkspaceTab[];
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { root: SplitNode; sidePaneId: string | null } {
|
|
const rootWithoutExplorer = removePaneFromTree(input.layout.root, input.legacyExplorerPane.id);
|
|
if (input.tabs.length === 0) {
|
|
return {
|
|
root: findPaneById(rootWithoutExplorer, input.legacyExplorerPane.id)
|
|
? createDefaultLayout().root
|
|
: rootWithoutExplorer,
|
|
sidePaneId: input.rememberedSidePane?.id ?? null,
|
|
};
|
|
}
|
|
|
|
if (input.rememberedSidePane) {
|
|
const rememberedTabs = collectAllTabs(input.layout.root).filter((tab) =>
|
|
input.rememberedSidePane?.tabIds.includes(tab.tabId),
|
|
);
|
|
const nextSidePane = visiblePane({
|
|
...input.rememberedSidePane,
|
|
tabIds: [...input.rememberedSidePane.tabIds, ...input.tabs.map((tab) => tab.tabId)],
|
|
tabs: [...rememberedTabs, ...input.tabs],
|
|
focusedTabId: input.rememberedSidePane.focusedTabId ?? input.tabs[0]?.tabId ?? null,
|
|
});
|
|
return {
|
|
root: replacePaneInNode(rootWithoutExplorer, input.rememberedSidePane.id, nextSidePane),
|
|
sidePaneId: input.rememberedSidePane.id,
|
|
};
|
|
}
|
|
|
|
const sidePaneId = input.ids.createNodeId("pane");
|
|
const sidePane = visiblePane({
|
|
...input.legacyExplorerPane,
|
|
id: sidePaneId,
|
|
tabIds: input.tabs.map((tab) => tab.tabId),
|
|
tabs: input.tabs,
|
|
focusedTabId:
|
|
input.tabs.find((tab) => tab.tabId === input.legacyExplorerPane.focusedTabId)?.tabId ??
|
|
input.tabs[0]?.tabId ??
|
|
null,
|
|
});
|
|
return {
|
|
root: replacePaneInNode(input.layout.root, input.legacyExplorerPane.id, sidePane),
|
|
sidePaneId,
|
|
};
|
|
}
|
|
|
|
function migrateVersionOneWorkspaceLayout(input: {
|
|
layout: WorkspaceLayout;
|
|
legacyExplorerPaneId: string | null | undefined;
|
|
rememberedSidePaneId: string | null | undefined;
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { layout: WorkspaceLayout; explorerPaneId: string; sidePaneId: string | null } {
|
|
const strippedLayout = stripEphemeralTabsFromLayout(input.layout);
|
|
const legacyExplorerPaneId = resolveExplorerSidebarPaneId(
|
|
strippedLayout,
|
|
input.legacyExplorerPaneId,
|
|
);
|
|
const legacyExplorerPane = findPaneById(strippedLayout.root, legacyExplorerPaneId);
|
|
if (!legacyExplorerPane) {
|
|
const ensured = ensurePersistedExplorerSidebarPane({
|
|
layout: strippedLayout,
|
|
registeredPaneId: null,
|
|
ids: input.ids,
|
|
});
|
|
return {
|
|
layout: ensured?.layout ?? strippedLayout,
|
|
explorerPaneId: ensured?.paneId ?? EXPLORER_SIDEBAR_PANE_ID,
|
|
sidePaneId: input.rememberedSidePaneId ?? null,
|
|
};
|
|
}
|
|
|
|
const preservedTabs = collectAllTabs(strippedLayout.root).filter(
|
|
(tab) =>
|
|
legacyExplorerPane.tabIds.includes(tab.tabId) &&
|
|
tab.target.kind !== "files" &&
|
|
tab.target.kind !== "changes_tree",
|
|
);
|
|
const preservedSide = preserveVersionOneSideTabs({
|
|
layout: strippedLayout,
|
|
legacyExplorerPane,
|
|
rememberedSidePane: findPaneById(strippedLayout.root, input.rememberedSidePaneId ?? null),
|
|
tabs: preservedTabs,
|
|
ids: input.ids,
|
|
});
|
|
const explorerNode = createExplorerSidebarNode();
|
|
const focusedPaneId =
|
|
strippedLayout.focusedPaneId === legacyExplorerPane.id
|
|
? (preservedSide.sidePaneId ?? collectAllPanes(preservedSide.root)[0]?.id ?? DEFAULT_PANE_ID)
|
|
: strippedLayout.focusedPaneId;
|
|
return {
|
|
layout: normalizeLayout({
|
|
root: {
|
|
kind: "group",
|
|
group: {
|
|
id: input.ids.createNodeId("group"),
|
|
direction: "horizontal",
|
|
children: [preservedSide.root, explorerNode],
|
|
sizes: [0.78, 0.22],
|
|
},
|
|
},
|
|
focusedPaneId,
|
|
parentTabIdByTabId: strippedLayout.parentTabIdByTabId,
|
|
}),
|
|
explorerPaneId: EXPLORER_SIDEBAR_PANE_ID,
|
|
sidePaneId: preservedSide.sidePaneId,
|
|
};
|
|
}
|
|
|
|
function migrateWorkspaceLayoutPersistedState(
|
|
persistedState: unknown,
|
|
version: number,
|
|
ids: WorkspaceLayoutIdSource,
|
|
): z.infer<typeof WorkspaceLayoutPersistedStateSchema> {
|
|
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
|
if (!result.success || version >= WORKSPACE_LAYOUT_PERSIST_VERSION) {
|
|
return result.success ? result.data : { layoutByWorkspace: {} };
|
|
}
|
|
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
const explorerPaneIdByWorkspace: Record<string, string | null> = {};
|
|
const sidePaneIdByWorkspace = { ...result.data.sidePaneIdByWorkspace };
|
|
for (const [workspaceKey, layout] of Object.entries(result.data.layoutByWorkspace)) {
|
|
const migrated = migrateVersionOneWorkspaceLayout({
|
|
layout,
|
|
legacyExplorerPaneId: result.data.explorerPaneIdByWorkspace?.[workspaceKey],
|
|
rememberedSidePaneId: result.data.sidePaneIdByWorkspace?.[workspaceKey],
|
|
ids,
|
|
});
|
|
layoutByWorkspace[workspaceKey] = migrated.layout;
|
|
explorerPaneIdByWorkspace[workspaceKey] = migrated.explorerPaneId;
|
|
sidePaneIdByWorkspace[workspaceKey] = migrated.sidePaneId;
|
|
}
|
|
|
|
return {
|
|
...result.data,
|
|
layoutByWorkspace,
|
|
explorerPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace,
|
|
};
|
|
}
|
|
|
|
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 createWorkspaceTabInstanceId(): string {
|
|
const value =
|
|
typeof globalThis.crypto?.randomUUID === "function"
|
|
? globalThis.crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
return `tab_${value}`;
|
|
}
|
|
|
|
function addAgentIdToWorkspaceSet(
|
|
state: Record<string, Set<string>>,
|
|
workspaceKey: string,
|
|
agentId: string,
|
|
): Record<string, Set<string>> {
|
|
const currentAgentIds = state[workspaceKey] ?? null;
|
|
if (currentAgentIds?.has(agentId)) {
|
|
return state;
|
|
}
|
|
|
|
const nextAgentIds = new Set(currentAgentIds ?? []);
|
|
nextAgentIds.add(agentId);
|
|
return {
|
|
...state,
|
|
[workspaceKey]: nextAgentIds,
|
|
};
|
|
}
|
|
|
|
function removeAgentIdFromWorkspaceSet(
|
|
state: Record<string, Set<string>>,
|
|
workspaceKey: string,
|
|
agentId: string,
|
|
): Record<string, Set<string>> {
|
|
const currentAgentIds = state[workspaceKey] ?? null;
|
|
if (!currentAgentIds?.has(agentId)) {
|
|
return state;
|
|
}
|
|
|
|
if (currentAgentIds.size === 1) {
|
|
const nextState = { ...state };
|
|
delete nextState[workspaceKey];
|
|
return nextState;
|
|
}
|
|
|
|
const nextAgentIds = new Set(currentAgentIds);
|
|
nextAgentIds.delete(agentId);
|
|
return {
|
|
...state,
|
|
[workspaceKey]: nextAgentIds,
|
|
};
|
|
}
|
|
|
|
function getWorkspaceLayout(
|
|
state: Record<string, WorkspaceLayout>,
|
|
workspaceKey: string,
|
|
): WorkspaceLayout {
|
|
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar());
|
|
}
|
|
|
|
function keepWorkspaceFocusOutOfExplorerSidebar(
|
|
layout: WorkspaceLayout,
|
|
explorerSidebarPaneId: string | null,
|
|
preferredMainPaneId?: string | null,
|
|
): WorkspaceLayout {
|
|
if (!explorerSidebarPaneId || layout.focusedPaneId !== explorerSidebarPaneId) {
|
|
return layout;
|
|
}
|
|
const panes = collectAllPanes(layout.root);
|
|
const preferredPane = panes.find(
|
|
(pane) => pane.id === preferredMainPaneId && pane.id !== explorerSidebarPaneId,
|
|
);
|
|
const mainPane =
|
|
preferredPane ??
|
|
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== explorerSidebarPaneId) ??
|
|
panes.find((pane) => pane.id !== explorerSidebarPaneId);
|
|
return { ...layout, focusedPaneId: mainPane?.id ?? null };
|
|
}
|
|
|
|
type ExplorerSidebarState = Pick<
|
|
WorkspaceLayoutStore,
|
|
"layoutByWorkspace" | "explorerSidebarPaneIdByWorkspace"
|
|
>;
|
|
|
|
/**
|
|
* The Explorer sidebar's pane, on screen or not. A workspace the user has not laid out
|
|
* yet still has one — the default layout is born with it — so this answers before
|
|
* the first tab exists.
|
|
*/
|
|
export function selectExplorerSidebarPaneId(
|
|
state: ExplorerSidebarState,
|
|
workspaceKey: string,
|
|
): string | null {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
|
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
|
}
|
|
|
|
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
|
export function selectPaneFocusSuspended(
|
|
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
): boolean {
|
|
return (
|
|
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
|
|
workspaceKey in state.sheetsByWorkspace
|
|
);
|
|
}
|
|
|
|
/** Whether the Explorer sidebar pane is currently on screen. */
|
|
export function selectIsExplorerSidebarVisible(
|
|
state: ExplorerSidebarState,
|
|
workspaceKey: string,
|
|
): boolean {
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
const paneId = layout ? selectExplorerSidebarPaneId(state, workspaceKey) : null;
|
|
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
|
return Boolean(pane && pane.hidden !== true);
|
|
}
|
|
|
|
export function resolveExplorerSidebarPaneId(
|
|
layout: WorkspaceLayout,
|
|
registeredPaneId: string | null | undefined,
|
|
): string | null {
|
|
const registeredPane = findPaneById(layout.root, registeredPaneId ?? null);
|
|
if (registeredPane) {
|
|
return registeredPane.id;
|
|
}
|
|
const defaultPane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID);
|
|
return defaultPane?.id ?? null;
|
|
}
|
|
|
|
function ensurePersistedExplorerSidebarPane(input: {
|
|
layout: WorkspaceLayout;
|
|
registeredPaneId: string | null | undefined;
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { layout: WorkspaceLayout; paneId: string } | null {
|
|
const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId);
|
|
if (existingPaneId) {
|
|
return {
|
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
input.layout,
|
|
existingPaneId,
|
|
input.layout.focusedPaneId,
|
|
),
|
|
paneId: existingPaneId,
|
|
};
|
|
}
|
|
const targetPaneId =
|
|
findPaneById(input.layout.root, input.layout.focusedPaneId)?.id ??
|
|
collectAllPanes(input.layout.root)[0]?.id;
|
|
if (!targetPaneId) {
|
|
return null;
|
|
}
|
|
const split = splitPaneEmptyInLayout({
|
|
layout: input.layout,
|
|
targetPaneId,
|
|
position: "right",
|
|
createNodeId: input.ids.createNodeId,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
});
|
|
if (!split) {
|
|
return null;
|
|
}
|
|
const hiddenLayout = setPaneHiddenInLayout({
|
|
layout: split.layout,
|
|
paneId: split.paneId,
|
|
hidden: true,
|
|
});
|
|
const seededLayout = restoreWorkspaceLayout(
|
|
stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout),
|
|
split.paneId,
|
|
);
|
|
return {
|
|
layout: seededLayout,
|
|
paneId: split.paneId,
|
|
};
|
|
}
|
|
|
|
function getOpenTabPlacement(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
placement: WorkspaceTabPlacement | undefined,
|
|
): {
|
|
layout: WorkspaceLayout;
|
|
placement: WorkspaceTabPlacement;
|
|
explorerSidebarPaneId: string | null;
|
|
} {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
);
|
|
const requestedPlacement = placement ?? AMBIENT_PLACEMENT;
|
|
const supportsPane = (pane: SplitPane) =>
|
|
panelTargetSupportsHostForWorkspaceKey(
|
|
workspaceKey,
|
|
target,
|
|
pane.id === explorerSidebarPaneId ? "explorer" : "main",
|
|
);
|
|
const requestedPaneId =
|
|
requestedPlacement.mode === "pane" || requestedPlacement.mode === "prefer"
|
|
? requestedPlacement.paneId
|
|
: layout.focusedPaneId;
|
|
const requestedPane = findPaneById(layout.root, requestedPaneId);
|
|
const fallbackPane = collectAllPanes(layout.root).find(
|
|
(pane) => pane.hidden !== true && supportsPane(pane),
|
|
);
|
|
let resolvedPlacement = requestedPlacement;
|
|
if ((!requestedPane || !supportsPane(requestedPane)) && fallbackPane) {
|
|
resolvedPlacement = {
|
|
mode: requestedPlacement.mode === "pane" ? "pane" : "prefer",
|
|
paneId: fallbackPane.id,
|
|
};
|
|
}
|
|
return {
|
|
layout,
|
|
placement: resolvedPlacement,
|
|
explorerSidebarPaneId,
|
|
};
|
|
}
|
|
|
|
function withoutFocusRestoration(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
): Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace"> | null {
|
|
if (!(workspaceKey in state.focusRestorationByWorkspace)) {
|
|
return null;
|
|
}
|
|
const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } =
|
|
state.focusRestorationByWorkspace;
|
|
return { focusRestorationByWorkspace };
|
|
}
|
|
|
|
function reconcileRememberedSidePane(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
layout: WorkspaceLayout,
|
|
): Pick<WorkspaceLayoutStore, "sidePaneIdByWorkspace"> {
|
|
const paneId = state.sidePaneIdByWorkspace[workspaceKey];
|
|
return {
|
|
sidePaneIdByWorkspace:
|
|
paneId && !findPaneById(layout.root, paneId)
|
|
? { ...state.sidePaneIdByWorkspace, [workspaceKey]: null }
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
}
|
|
|
|
function attachParentTab(input: {
|
|
layout: WorkspaceLayout;
|
|
childTabId: string | null;
|
|
parentTabId: string | null;
|
|
}): WorkspaceLayout {
|
|
const childTabId = trimNonEmpty(input.childTabId);
|
|
const parentTabId = trimNonEmpty(input.parentTabId);
|
|
if (!childTabId || !parentTabId || childTabId === parentTabId) {
|
|
return normalizeLayout(input.layout);
|
|
}
|
|
|
|
const openTabIds = new Set(collectAllTabs(input.layout.root).map((tab) => tab.tabId));
|
|
if (!openTabIds.has(childTabId) || !openTabIds.has(parentTabId)) {
|
|
return normalizeLayout(input.layout);
|
|
}
|
|
|
|
return normalizeLayout({
|
|
...input.layout,
|
|
parentTabIdByTabId: {
|
|
...input.layout.parentTabIdByTabId,
|
|
[childTabId]: parentTabId,
|
|
},
|
|
});
|
|
}
|
|
|
|
const NO_SHEETS: readonly SheetTab[] = [];
|
|
|
|
function getSheets(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
): readonly SheetTab[] {
|
|
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
|
|
}
|
|
|
|
function findSheet(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
): SheetTab | undefined {
|
|
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
|
|
}
|
|
|
|
function withSheets(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
sheets: readonly SheetTab[],
|
|
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
|
if (sheets.length > 0) {
|
|
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
|
|
}
|
|
if (!(workspaceKey in state.sheetsByWorkspace)) {
|
|
return { sheetsByWorkspace: state.sheetsByWorkspace };
|
|
}
|
|
const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace;
|
|
return { sheetsByWorkspace };
|
|
}
|
|
|
|
function withoutSheet(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
|
return withSheets(
|
|
state,
|
|
workspaceKey,
|
|
getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId),
|
|
);
|
|
}
|
|
|
|
function withSheetTab(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
update: (sheet: SheetTab) => SheetTab,
|
|
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
|
return withSheets(
|
|
state,
|
|
workspaceKey,
|
|
getSheets(state, workspaceKey).map((sheet) =>
|
|
sheet.tab.tabId === tabId ? update(sheet) : sheet,
|
|
),
|
|
);
|
|
}
|
|
|
|
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
|
|
if (!layout) return false;
|
|
const resourceKey = panelResourceKey(target);
|
|
return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey);
|
|
}
|
|
|
|
function resolveSheetPolicy(
|
|
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace">,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
intent: WorkspaceTabOpenIntent,
|
|
): CompactSheetPolicy | null {
|
|
if (state.layoutMode !== "compact" || intent === "background") return null;
|
|
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
|
return null;
|
|
}
|
|
return resolveCompactSheetPolicy(workspaceKey, target);
|
|
}
|
|
|
|
/**
|
|
* The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened
|
|
* from inside a sheet inherits that sheet's own parent instead.
|
|
*/
|
|
function resolveOpenParentTabId(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
parentTabId: string | undefined,
|
|
): string | null {
|
|
if (!parentTabId) return null;
|
|
return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId;
|
|
}
|
|
|
|
function placeTargetInSheet(input: {
|
|
/** The sheet whose tab the target keeps, if any. */
|
|
kept: SheetTab | undefined;
|
|
target: WorkspaceTabTarget;
|
|
intent: WorkspaceTabOpenIntent;
|
|
policy: CompactSheetPolicy;
|
|
parentTabId: string | null;
|
|
state: JsonValue | undefined;
|
|
}): SheetTab {
|
|
if (input.kept) {
|
|
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
|
|
}
|
|
return {
|
|
tab: {
|
|
tabId:
|
|
input.intent === "reveal"
|
|
? buildDeterministicWorkspaceTabId(input.target)
|
|
: createWorkspaceTabInstanceId(),
|
|
target: input.target,
|
|
createdAt: Date.now(),
|
|
...(input.state !== undefined ? { state: input.state } : {}),
|
|
},
|
|
policy: input.policy,
|
|
parentTabId: input.parentTabId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The
|
|
* other sheets stay; callers that bring the tab to the front dismiss them.
|
|
*/
|
|
function promoteSheetToTab(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
|
const sheet = findSheet(state, workspaceKey, tabId);
|
|
if (!sheet) return null;
|
|
const { tab } = sheet;
|
|
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
|
const input = {
|
|
...placement,
|
|
target: tab.target,
|
|
now: tab.createdAt,
|
|
createTabId: () => tab.tabId,
|
|
state: tab.state,
|
|
};
|
|
const result = isTargetOpenAsTab(placement.layout, tab.target)
|
|
? revealTargetInLayout(input)
|
|
: createTabInLayout(input);
|
|
if (!result) return null;
|
|
const nextLayout = attachParentTab({
|
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
result.layout,
|
|
placement.explorerSidebarPaneId,
|
|
placement.layout.focusedPaneId,
|
|
),
|
|
childTabId: result.tabId,
|
|
parentTabId: sheet.parentTabId,
|
|
});
|
|
return {
|
|
tabId: result.tabId,
|
|
patch: {
|
|
...withoutSheet(state, workspaceKey, tab.tabId),
|
|
...withoutFocusRestoration(state, workspaceKey),
|
|
hiddenAgentIdsByWorkspace:
|
|
tab.target.kind === "agent"
|
|
? removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
workspaceKey,
|
|
tab.target.agentId,
|
|
)
|
|
: state.hiddenAgentIdsByWorkspace,
|
|
layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
|
|
* panel and drop the work. Only the explicit, guarded close removes it.
|
|
*/
|
|
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean {
|
|
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
|
|
}
|
|
|
|
/**
|
|
* Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on
|
|
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by
|
|
* closing the sheets above it, except ones holding unsaved work, which stay where they are.
|
|
*/
|
|
function resolveSheetPlacement(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
intent: WorkspaceTabOpenIntent,
|
|
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
|
|
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
|
|
if (!policy) return null;
|
|
if (intent !== "reveal") return { policy, keptIndex: null };
|
|
const sheets = getSheets(state, workspaceKey);
|
|
const resourceKey = panelResourceKey(target);
|
|
const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey);
|
|
return { policy, keptIndex: index === -1 ? null : index };
|
|
}
|
|
|
|
/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */
|
|
function dismissSheets(
|
|
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
|
workspaceKey: string,
|
|
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
|
return withSheets(
|
|
state,
|
|
workspaceKey,
|
|
getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)),
|
|
);
|
|
}
|
|
|
|
/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */
|
|
function openSheetTabInFront(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
|
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
|
|
if (!promoted) return null;
|
|
const next = { ...state, ...promoted.patch };
|
|
return {
|
|
tabId: promoted.tabId,
|
|
patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would,
|
|
* and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet
|
|
* would hold a second tab with the same id.
|
|
*/
|
|
function sheetAgentNoLongerFits(
|
|
workspaceKey: string,
|
|
sheet: SheetTab,
|
|
snapshot: WorkspaceTabSnapshot,
|
|
): boolean {
|
|
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
|
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
|
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
|
|
}
|
|
|
|
/**
|
|
* Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved
|
|
* before the Explorer became part of the default tree; new ones are born with it.
|
|
*/
|
|
function createExplorerSidebarPane(
|
|
workspaceKey: string,
|
|
layout: WorkspaceLayout,
|
|
splitPaneEmpty: WorkspaceLayoutStore["splitPaneEmpty"],
|
|
): string | null {
|
|
const targetPaneId =
|
|
findPaneById(layout.root, layout.focusedPaneId)?.id ?? collectAllPanes(layout.root)[0]?.id;
|
|
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
|
|
}
|
|
|
|
export function createWorkspaceLayoutStore(
|
|
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
|
) {
|
|
return create<WorkspaceLayoutStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
autoOpenPullRequestTab: (workspaceKey, resolvePlacement) => {
|
|
const key = trimNonEmpty(workspaceKey);
|
|
if (!key || get().pullRequestTabAutoOpenedByWorkspace[key]) return null;
|
|
const tabId = get().openTab({
|
|
workspaceKey: key,
|
|
target: { kind: "pull_request" },
|
|
intent: "background",
|
|
...resolvePlacement(),
|
|
});
|
|
if (tabId) {
|
|
set((state) => ({
|
|
pullRequestTabAutoOpenedByWorkspace: {
|
|
...state.pullRequestTabAutoOpenedByWorkspace,
|
|
[key]: true,
|
|
},
|
|
}));
|
|
}
|
|
return tabId;
|
|
},
|
|
layoutMode: "split-panes",
|
|
sheetsByWorkspace: {},
|
|
layoutByWorkspace: {},
|
|
splitSizesByWorkspace: {},
|
|
explorerSidebarWidthByWorkspace: {},
|
|
pinnedAgentIdsByWorkspace: {},
|
|
hiddenAgentIdsByWorkspace: {},
|
|
focusRestorationByWorkspace: {},
|
|
explorerSidebarPaneIdByWorkspace: {},
|
|
sidePaneIdByWorkspace: {},
|
|
pullRequestTabAutoOpenedByWorkspace: {},
|
|
openTab: (input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
|
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
|
return null;
|
|
}
|
|
const parentTabId = resolveOpenParentTabId(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
input.parentTabId,
|
|
);
|
|
const sheetPlacement = resolveSheetPlacement(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget,
|
|
input.intent,
|
|
);
|
|
if (sheetPlacement) {
|
|
const sheets = getSheets(get(), normalizedWorkspaceKey);
|
|
const { keptIndex } = sheetPlacement;
|
|
const sheet = placeTargetInSheet({
|
|
kept: keptIndex === null ? undefined : sheets[keptIndex],
|
|
target: normalizedTarget,
|
|
intent: input.intent,
|
|
policy: sheetPlacement.policy,
|
|
parentTabId,
|
|
state: input.state,
|
|
});
|
|
const next =
|
|
keptIndex === null
|
|
? [...sheets, sheet]
|
|
: sheets.flatMap((existing, index) => {
|
|
if (index === keptIndex) return [sheet];
|
|
if (index < keptIndex) return [existing];
|
|
return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing)
|
|
? [existing]
|
|
: [];
|
|
});
|
|
set((state) => withSheets(state, normalizedWorkspaceKey, next));
|
|
return sheet.tab.tabId;
|
|
}
|
|
const placement = getOpenTabPlacement(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget,
|
|
input.placement,
|
|
);
|
|
let result;
|
|
if (input.intent === "new") {
|
|
result = createTabInLayout({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
state: input.state,
|
|
});
|
|
} else if (input.intent === "background") {
|
|
result = openTabInLayoutBackground({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
});
|
|
} else {
|
|
result = revealTargetInLayout({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
});
|
|
}
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
result.layout,
|
|
placement.explorerSidebarPaneId,
|
|
placement.layout.focusedPaneId,
|
|
);
|
|
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
// A tab brought to the front dismisses the sheet over it.
|
|
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
|
|
hiddenAgentIdsByWorkspace:
|
|
normalizedTarget.kind !== "agent"
|
|
? state.hiddenAgentIdsByWorkspace
|
|
: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
),
|
|
pinnedAgentIdsByWorkspace: shouldPinAgent
|
|
? addAgentIdToWorkspaceSet(
|
|
state.pinnedAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
)
|
|
: state.pinnedAgentIdsByWorkspace,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: parentTabId
|
|
? attachParentTab({
|
|
layout: nextLayout,
|
|
childTabId: result.tabId,
|
|
parentTabId,
|
|
})
|
|
: nextLayout,
|
|
},
|
|
}));
|
|
return result.tabId;
|
|
},
|
|
opensInSheet: (input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
|
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
|
return (
|
|
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
|
null
|
|
);
|
|
},
|
|
setLayoutMode: (mode) => {
|
|
set((state) => {
|
|
if (state.layoutMode === mode) return state;
|
|
if (mode === "compact") return { layoutMode: mode };
|
|
// Widening keeps whatever the user had open in sheets as ordinary tabs.
|
|
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
|
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
|
|
for (const sheet of sheets) {
|
|
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
|
|
Object.assign(
|
|
next,
|
|
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
|
|
);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
openSheetAsTab: (workspaceKey, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
|
|
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
|
|
if (!promoted) return null;
|
|
set(promoted.patch);
|
|
return promoted.tabId;
|
|
},
|
|
showExplorerSidebar: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
|
|
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const paneId =
|
|
resolveExplorerSidebarPaneId(
|
|
layout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
) ?? createExplorerSidebarPane(normalizedWorkspaceKey, layout, get().splitPaneEmpty);
|
|
if (!paneId) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => {
|
|
const currentLayout = getWorkspaceLayout(
|
|
state.layoutByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
);
|
|
const revealedLayout =
|
|
setPaneHiddenInLayout({ layout: currentLayout, paneId, hidden: false }) ??
|
|
currentLayout;
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
revealedLayout,
|
|
paneId,
|
|
currentLayout.focusedPaneId,
|
|
),
|
|
},
|
|
explorerSidebarPaneIdByWorkspace: {
|
|
...state.explorerSidebarPaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: paneId,
|
|
},
|
|
};
|
|
});
|
|
return paneId;
|
|
},
|
|
hideExplorerSidebar: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const paneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const nextLayout = paneId
|
|
? setPaneHiddenInLayout({ layout, paneId, hidden: true })
|
|
: null;
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
ensureSidePane: (workspaceKey, options) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
const currentState = get();
|
|
const layout = getWorkspaceLayout(currentState.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
currentState.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const rememberedPaneId = trimNonEmpty(
|
|
currentState.sidePaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const rememberedPane = findPaneById(layout.root, rememberedPaneId);
|
|
if (rememberedPane && rememberedPane.id !== explorerPaneId) {
|
|
return rememberedPane.id;
|
|
}
|
|
|
|
const result = splitWorkspaceRootRightInLayout({
|
|
layout,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]:
|
|
options?.focus === false
|
|
? { ...result.layout, focusedPaneId: layout.focusedPaneId }
|
|
: result.layout,
|
|
},
|
|
sidePaneIdByWorkspace: {
|
|
...state.sidePaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: result.paneId,
|
|
},
|
|
}));
|
|
return result.paneId;
|
|
},
|
|
closeTab: (workspaceKey, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
|
|
return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId);
|
|
}
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
|
|
const closingTab = collectAllTabs(layout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
if (closingPane?.tabIds.length === 1 && closingTab?.target.kind === "new_tab") {
|
|
const nextLayout =
|
|
closingPane.id === explorerSidebarPaneId
|
|
? setPaneHiddenInLayout({
|
|
layout,
|
|
paneId: closingPane.id,
|
|
hidden: true,
|
|
})
|
|
: closePaneInLayout({
|
|
layout,
|
|
paneId: closingPane.id,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
...reconcileRememberedSidePane(state, normalizedWorkspaceKey, nextLayout),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
}
|
|
const closedLayout = closeTabInLayout({
|
|
layout,
|
|
tabId: normalizedTabId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
const nextLayoutBeforeFocusNormalization =
|
|
closedLayout &&
|
|
closingPane?.id === explorerSidebarPaneId &&
|
|
closingPane.tabIds.length === 1
|
|
? (setPaneHiddenInLayout({
|
|
layout: closedLayout,
|
|
paneId: explorerSidebarPaneId,
|
|
hidden: true,
|
|
}) ?? closedLayout)
|
|
: closedLayout;
|
|
const nextLayout = nextLayoutBeforeFocusNormalization
|
|
? keepWorkspaceFocusOutOfExplorerSidebar(
|
|
nextLayoutBeforeFocusNormalization,
|
|
explorerSidebarPaneId,
|
|
layout.focusedPaneId,
|
|
)
|
|
: null;
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
...reconcileRememberedSidePane(state, normalizedWorkspaceKey, nextLayout),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
focusTab: (workspaceKey, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const tabPane = findPaneContainingTab(layout.root, normalizedTabId);
|
|
let nextLayout: WorkspaceLayout | null;
|
|
if (tabPane?.id === explorerSidebarPaneId) {
|
|
const revealedLayout =
|
|
setPaneHiddenInLayout({
|
|
layout,
|
|
paneId: explorerSidebarPaneId,
|
|
hidden: false,
|
|
}) ?? layout;
|
|
nextLayout =
|
|
selectTabInPaneInLayout({
|
|
layout: revealedLayout,
|
|
paneId: explorerSidebarPaneId,
|
|
tabId: normalizedTabId,
|
|
}) ?? revealedLayout;
|
|
} else {
|
|
nextLayout = focusTabInLayout({ layout, tabId: normalizedTabId });
|
|
}
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
// Choosing a tab dismisses the sheets that cover it.
|
|
...dismissSheets(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
selectTabInPane: (workspaceKey, paneId, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId || !normalizedTabId) {
|
|
return;
|
|
}
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const nextLayout = selectTabInPaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
tabId: normalizedTabId,
|
|
});
|
|
return nextLayout
|
|
? {
|
|
// Choosing a tab dismisses the sheets that cover it.
|
|
...dismissSheets(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
}
|
|
: state;
|
|
});
|
|
},
|
|
replaceTab: (workspaceKey, tabId, target, tabState) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
|
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
|
|
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
|
|
const next: WorkspaceLayoutStore = {
|
|
...get(),
|
|
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
|
|
...sheet,
|
|
policy: policy ?? sheet.policy,
|
|
tab: {
|
|
...sheet.tab,
|
|
target: normalizedTarget,
|
|
...(tabState !== undefined ? { state: tabState } : {}),
|
|
},
|
|
})),
|
|
};
|
|
if (policy) {
|
|
set({ sheetsByWorkspace: next.sheetsByWorkspace });
|
|
return normalizedTabId;
|
|
}
|
|
// A target sheets do not host becomes the tab it would have opened as.
|
|
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
|
|
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
|
|
return promoted?.tabId ?? null;
|
|
}
|
|
const result = replaceTabTargetInLayout({
|
|
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabId: normalizedTabId,
|
|
target: normalizedTarget,
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
state: tabState,
|
|
});
|
|
if (!result) return null;
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
hiddenAgentIdsByWorkspace:
|
|
normalizedTarget.kind !== "agent"
|
|
? state.hiddenAgentIdsByWorkspace
|
|
: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
return result.tabId;
|
|
},
|
|
setTabState: (workspaceKey, tabId, tabState) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
|
set((state) => {
|
|
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
|
|
return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => {
|
|
const { state: _previous, ...tab } = sheet.tab;
|
|
return {
|
|
...sheet,
|
|
tab: tabState === undefined ? tab : { ...tab, state: tabState },
|
|
};
|
|
});
|
|
}
|
|
const layout = setTabStateInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabId: normalizedTabId,
|
|
state: tabState,
|
|
});
|
|
if (!layout) return state;
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: layout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) {
|
|
return null;
|
|
}
|
|
|
|
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const result = convertDraftToAgentInLayout({
|
|
layout,
|
|
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
|
|
layout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
),
|
|
tabId: normalizedTabId,
|
|
agentId: normalizedAgentId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...(result.layout.focusedPaneId !== null
|
|
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
|
: {}),
|
|
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.tabId;
|
|
},
|
|
reconcileTabs: (workspaceKey, snapshot) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
rawLayout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
rawLayout,
|
|
explorerSidebarPaneId,
|
|
rawLayout.focusedPaneId,
|
|
);
|
|
const nextState = reconcileWorkspaceTabs(
|
|
{
|
|
layout: currentLayout,
|
|
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
|
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
|
explorerSidebarPaneId,
|
|
},
|
|
snapshot,
|
|
);
|
|
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
nextState.layout,
|
|
explorerSidebarPaneId,
|
|
currentLayout.focusedPaneId,
|
|
);
|
|
let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace;
|
|
for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) {
|
|
if (!nextState.pinnedAgentIds?.has(agentId)) {
|
|
pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
|
|
pinnedAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
agentId,
|
|
);
|
|
}
|
|
}
|
|
const sheets = getSheets(state, normalizedWorkspaceKey);
|
|
const fittingSheets = sheets.filter(
|
|
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
|
|
);
|
|
const sheetAgentGone = fittingSheets.length !== sheets.length;
|
|
if (
|
|
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
|
nextLayout === rawLayout &&
|
|
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
|
|
!sheetAgentGone
|
|
) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}),
|
|
pinnedAgentIdsByWorkspace,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
reorderTabs: (workspaceKey, tabIds) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextLayout = reorderFocusedPaneTabsInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabIds,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
getWorkspaceTabs: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return [];
|
|
}
|
|
return collectAllTabs(
|
|
getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root,
|
|
);
|
|
},
|
|
splitPane: (workspaceKey, input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(input.tabId);
|
|
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const currentLayout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
currentLayout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedTargetPaneId === explorerSidebarPaneId) {
|
|
return null;
|
|
}
|
|
const movingTab = collectAllTabs(currentLayout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
if (
|
|
!movingTab ||
|
|
!panelTargetSupportsHostForWorkspaceKey(
|
|
normalizedWorkspaceKey,
|
|
movingTab.target,
|
|
"main",
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const result = splitPaneInLayout({
|
|
layout: currentLayout,
|
|
tabId: normalizedTabId,
|
|
targetPaneId: normalizedTargetPaneId,
|
|
position: input.position,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.paneId;
|
|
},
|
|
splitPaneEmpty: (workspaceKey, input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTargetPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const currentLayout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
currentLayout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedTargetPaneId === explorerSidebarPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const result = splitPaneEmptyInLayout({
|
|
layout: currentLayout,
|
|
targetPaneId: normalizedTargetPaneId,
|
|
position: input.position,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.paneId;
|
|
},
|
|
moveTabToPane: (workspaceKey, tabId, toPaneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedToPaneId = trimNonEmpty(toPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const movingTab = collectAllTabs(layout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const destinationHost =
|
|
normalizedToPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
|
if (
|
|
!movingTab ||
|
|
!panelTargetSupportsHostForWorkspaceKey(
|
|
normalizedWorkspaceKey,
|
|
movingTab.target,
|
|
destinationHost,
|
|
)
|
|
) {
|
|
return state;
|
|
}
|
|
const nextLayout = moveTabToPaneInLayout({
|
|
layout,
|
|
tabId: normalizedTabId,
|
|
toPaneId: normalizedToPaneId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
const restoredLayout =
|
|
normalizedToPaneId === explorerSidebarPaneId
|
|
? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId)
|
|
: nextLayout;
|
|
const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
restoredLayout,
|
|
explorerSidebarPaneId,
|
|
layout.focusedPaneId,
|
|
);
|
|
const rememberedSidePaneId = state.sidePaneIdByWorkspace[normalizedWorkspaceKey];
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: normalizedNextLayout,
|
|
},
|
|
sidePaneIdByWorkspace:
|
|
rememberedSidePaneId &&
|
|
!findPaneById(normalizedNextLayout.root, rememberedSidePaneId)
|
|
? { ...state.sidePaneIdByWorkspace, [normalizedWorkspaceKey]: null }
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
closePane: (workspaceKey, paneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
// The Explorer is a surface the user summons, so closing it puts it
|
|
// away rather than dismantling the split it lives in.
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const isExplorerSidebar = explorerSidebarPaneId === normalizedPaneId;
|
|
const nextLayout = isExplorerSidebar
|
|
? setPaneHiddenInLayout({ layout, paneId: normalizedPaneId, hidden: true })
|
|
: closePaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
sidePaneIdByWorkspace:
|
|
state.sidePaneIdByWorkspace[normalizedWorkspaceKey] === normalizedPaneId
|
|
? {
|
|
...state.sidePaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: null,
|
|
}
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
focusPane: (workspaceKey, paneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedPaneId === explorerSidebarPaneId) {
|
|
return state;
|
|
}
|
|
const nextLayout = focusPaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
unfocusPane: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
|
|
const token = ids.createFocusRestorationToken();
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
|
const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId;
|
|
|
|
return {
|
|
focusRestorationByWorkspace: {
|
|
...state.focusRestorationByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
restorePaneId,
|
|
tokens: [...(currentRestoration?.tokens ?? []), token],
|
|
},
|
|
},
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]:
|
|
layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null },
|
|
},
|
|
};
|
|
});
|
|
return token;
|
|
},
|
|
restorePaneFocus: (workspaceKey, token) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedToken = trimNonEmpty(token);
|
|
if (!normalizedWorkspaceKey || !normalizedToken) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
|
if (!restoration?.tokens.includes(normalizedToken)) {
|
|
return state;
|
|
}
|
|
|
|
const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken);
|
|
const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } =
|
|
state.focusRestorationByWorkspace;
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
|
|
if (layout.focusedPaneId !== null) {
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
};
|
|
}
|
|
|
|
if (nextTokens.length > 0) {
|
|
return {
|
|
focusRestorationByWorkspace: {
|
|
...remainingRestorations,
|
|
[normalizedWorkspaceKey]: {
|
|
restorePaneId: restoration.restorePaneId,
|
|
tokens: nextTokens,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const restorePane = findPaneById(layout.root, restoration.restorePaneId);
|
|
const restorePaneId = restorePane?.hidden === true ? null : (restorePane?.id ?? null);
|
|
if (!restorePaneId) {
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
};
|
|
}
|
|
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
...layout,
|
|
focusedPaneId: restorePaneId,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
resizeSplit: (workspaceKey, groupId, sizes) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedGroupId = trimNonEmpty(groupId);
|
|
if (!normalizedWorkspaceKey || !normalizedGroupId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => ({
|
|
splitSizesByWorkspace: {
|
|
...state.splitSizesByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
...state.splitSizesByWorkspace[normalizedWorkspaceKey],
|
|
[normalizedGroupId]: clampNormalizedSizes(sizes),
|
|
},
|
|
},
|
|
}));
|
|
},
|
|
resizeExplorerSidebar: (workspaceKey, width) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey || !Number.isFinite(width) || width <= 0) {
|
|
return;
|
|
}
|
|
|
|
set((state) => ({
|
|
explorerSidebarWidthByWorkspace: {
|
|
...state.explorerSidebarWidthByWorkspace,
|
|
[normalizedWorkspaceKey]: width,
|
|
},
|
|
}));
|
|
},
|
|
reorderTabsInPane: (workspaceKey, paneId, tabIds) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextLayout = reorderPaneTabsInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
paneId: normalizedPaneId,
|
|
tabIds,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
unpinAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const currentPinnedAgentIds =
|
|
state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
|
if (!currentPinnedAgentIds?.has(normalizedAgentId)) {
|
|
return state;
|
|
}
|
|
|
|
if (currentPinnedAgentIds.size === 1) {
|
|
const nextPinnedAgentIdsByWorkspace = {
|
|
...state.pinnedAgentIdsByWorkspace,
|
|
};
|
|
delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey];
|
|
return {
|
|
pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace,
|
|
};
|
|
}
|
|
|
|
const nextPinnedAgentIds = new Set(currentPinnedAgentIds);
|
|
nextPinnedAgentIds.delete(normalizedAgentId);
|
|
|
|
return {
|
|
pinnedAgentIdsByWorkspace: {
|
|
...state.pinnedAgentIdsByWorkspace,
|
|
[normalizedWorkspaceKey]: nextPinnedAgentIds,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
hideAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextHiddenAgentIdsByWorkspace = addAgentIdToWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
);
|
|
if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
unhideAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextHiddenAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
);
|
|
if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
purgeWorkspace: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const hasAny =
|
|
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
|
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
|
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
|
|
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
|
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
|
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
|
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
|
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
|
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
|
|
normalizedWorkspaceKey in state.sheetsByWorkspace;
|
|
if (!hasAny) {
|
|
return state;
|
|
}
|
|
const {
|
|
[normalizedWorkspaceKey]: _autoOpened,
|
|
...pullRequestTabAutoOpenedByWorkspace
|
|
} = state.pullRequestTabAutoOpenedByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } =
|
|
state.layoutByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } =
|
|
state.splitSizesByWorkspace;
|
|
const {
|
|
[normalizedWorkspaceKey]: _explorerSidebarWidth,
|
|
...explorerSidebarWidthByWorkspace
|
|
} = state.explorerSidebarWidthByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
|
|
state.pinnedAgentIdsByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
|
state.hiddenAgentIdsByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
|
state.focusRestorationByWorkspace;
|
|
const {
|
|
[normalizedWorkspaceKey]: _explorerSidebarPane,
|
|
...explorerSidebarPaneIdByWorkspace
|
|
} = state.explorerSidebarPaneIdByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
|
state.sidePaneIdByWorkspace;
|
|
return {
|
|
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
|
|
pullRequestTabAutoOpenedByWorkspace,
|
|
layoutByWorkspace,
|
|
splitSizesByWorkspace,
|
|
explorerSidebarWidthByWorkspace,
|
|
pinnedAgentIdsByWorkspace,
|
|
hiddenAgentIdsByWorkspace,
|
|
focusRestorationByWorkspace,
|
|
explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: "workspace-layout-state",
|
|
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
|
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
|
migrate: (persistedState, version) =>
|
|
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
|
partialize: (state) => {
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
for (const key in state.layoutByWorkspace) {
|
|
// Strip ephemeral (commit diff) tabs before persisting so they are
|
|
// dropped on reload rather than restored pointing at a rebased SHA.
|
|
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
|
normalizeLayout(state.layoutByWorkspace[key]),
|
|
);
|
|
}
|
|
return {
|
|
layoutByWorkspace,
|
|
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
|
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
|
key,
|
|
Array.from(agentIds),
|
|
]),
|
|
),
|
|
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
|
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
|
|
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
|
|
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
|
|
};
|
|
},
|
|
merge: (persistedState, currentState) => {
|
|
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
|
if (!result.success) {
|
|
return currentState;
|
|
}
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
|
|
...(result.data.explorerSidebarPaneIdByWorkspace ??
|
|
result.data.explorerPaneIdByWorkspace),
|
|
};
|
|
for (const [workspaceKey, persistedLayout] of Object.entries(
|
|
result.data.layoutByWorkspace,
|
|
)) {
|
|
const restoredLayout = restoreWorkspaceLayout(
|
|
stripEphemeralTabsFromLayout(persistedLayout),
|
|
resolveExplorerSidebarPaneId(
|
|
persistedLayout,
|
|
explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
),
|
|
ids.createNodeId,
|
|
);
|
|
const explorerSidebar = ensurePersistedExplorerSidebarPane({
|
|
layout: restoredLayout,
|
|
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
ids,
|
|
});
|
|
layoutByWorkspace[workspaceKey] = explorerSidebar?.layout ?? restoredLayout;
|
|
if (explorerSidebar) {
|
|
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
|
|
}
|
|
}
|
|
return {
|
|
...currentState,
|
|
layoutByWorkspace,
|
|
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
|
Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [
|
|
key,
|
|
new Set(agentIds),
|
|
]),
|
|
),
|
|
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
|
explorerSidebarWidthByWorkspace:
|
|
result.data.explorerSidebarWidthByWorkspace ??
|
|
convertLegacyExplorerSidebarRatios(
|
|
result.data.explorerSidebarRatioByWorkspace ??
|
|
result.data.sidePanelRatioByWorkspace ??
|
|
{},
|
|
),
|
|
explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace: result.data.sidePaneIdByWorkspace ?? {},
|
|
pullRequestTabAutoOpenedByWorkspace:
|
|
result.data.pullRequestTabAutoOpenedByWorkspace ?? {},
|
|
};
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
|
|
|
/**
|
|
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
|
* independently of which workspace views are mounted. Timeline sync uses this to release a chat
|
|
* when its tab closes. It is not a subscription source: this layout is restored from disk at
|
|
* launch, so subscribing to it would resume every agent the user has ever opened.
|
|
*/
|
|
export function observeOpenWorkspaceAgentIds(
|
|
serverId: string,
|
|
listener: (agentIds: string[]) => void,
|
|
store = useWorkspaceLayoutStore,
|
|
): () => void {
|
|
let previous: string[] | undefined;
|
|
const publish = (
|
|
state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetsByWorkspace">,
|
|
) => {
|
|
const ids = new Set<string>();
|
|
const tabsByWorkspace = [
|
|
...Object.entries(state.layoutByWorkspace).map(
|
|
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
|
|
),
|
|
...Object.entries(state.sheetsByWorkspace).map(
|
|
([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const,
|
|
),
|
|
];
|
|
for (const [workspaceKey, tabs] of tabsByWorkspace) {
|
|
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
|
|
for (const tab of tabs) {
|
|
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
|
|
}
|
|
}
|
|
const next = [...ids].sort();
|
|
if (previous?.length === next.length && previous.every((id, index) => id === next[index]))
|
|
return;
|
|
previous = next;
|
|
listener(next);
|
|
};
|
|
const unsubscribe = store.subscribe((state, before) => {
|
|
if (
|
|
state.layoutByWorkspace !== before.layoutByWorkspace ||
|
|
state.sheetsByWorkspace !== before.sheetsByWorkspace
|
|
) {
|
|
publish(state);
|
|
}
|
|
});
|
|
publish(store.getState());
|
|
return unsubscribe;
|
|
}
|
|
|
|
export function useWorkspaceLayoutStoreHydrated(): boolean {
|
|
const [hasHydrated, setHasHydrated] = useState(() =>
|
|
useWorkspaceLayoutStore.persist.hasHydrated(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (useWorkspaceLayoutStore.persist.hasHydrated()) {
|
|
setHasHydrated(true);
|
|
return;
|
|
}
|
|
|
|
return useWorkspaceLayoutStore.persist.onFinishHydration(() => {
|
|
setHasHydrated(true);
|
|
});
|
|
}, []);
|
|
|
|
return hasHydrated;
|
|
}
|