feat(app): make the side panel user-directed (#3605)
* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
This commit is contained in:
@@ -37,8 +37,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
||||
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
||||
getSessionAgents: (serverId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
||||
openTabFocused: (workspaceKey, target) =>
|
||||
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, target),
|
||||
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
|
||||
pinAgent: (workspaceKey, agentId) =>
|
||||
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
|
||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||
|
||||
@@ -23,7 +23,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
const deps: NavigateToWorkspaceDeps = {
|
||||
getSessionWorkspaces: () => null,
|
||||
getSessionAgents: () => [] as Agent[],
|
||||
openTabFocused: (workspaceKey, target) => {
|
||||
openTab: ({ workspaceKey, target }) => {
|
||||
openedTabs.push({ workspaceKey, target });
|
||||
return target.kind === "agent" ? target.agentId : null;
|
||||
},
|
||||
|
||||
@@ -100,9 +100,10 @@ export function navigateToWorkspace(
|
||||
: [];
|
||||
const attentionAgentId = pickAttentionAgent(workspaceAgents);
|
||||
if (attentionAgentId && resolvedWorkspaceId) {
|
||||
deps.openTabFocused(`${input.serverId}:${resolvedWorkspaceId}`, {
|
||||
kind: "agent",
|
||||
agentId: attentionAgentId,
|
||||
deps.openTab({
|
||||
workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`,
|
||||
target: { kind: "agent", agentId: attentionAgentId },
|
||||
intent: "reveal",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ export interface PanelState {
|
||||
explorerSortOption: SortOption;
|
||||
explorerShowHiddenFiles: boolean;
|
||||
treeRailWidth: number;
|
||||
// File panel's tree rail. The changes panel keeps its own flag in
|
||||
// `useChangesPreferences`; the two rails open and close independently.
|
||||
fileTreeVisible: boolean;
|
||||
|
||||
// Actions
|
||||
toggleFocusMode: () => void;
|
||||
@@ -103,6 +106,7 @@ export interface PanelState {
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
toggleExplorerShowHiddenFiles: () => void;
|
||||
setTreeRailWidth: (width: number) => void;
|
||||
toggleFileTreeVisible: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||
@@ -137,6 +141,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
explorerSortOption: "name",
|
||||
explorerShowHiddenFiles: true,
|
||||
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
|
||||
fileTreeVisible: true,
|
||||
|
||||
toggleFocusMode: () =>
|
||||
set((state) => ({
|
||||
@@ -278,10 +283,11 @@ export const usePanelStore = create<PanelState>()(
|
||||
toggleExplorerShowHiddenFiles: () =>
|
||||
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
|
||||
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
|
||||
toggleFileTreeVisible: () => set((state) => ({ fileTreeVisible: !state.fileTreeVisible })),
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 15,
|
||||
version: 16,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
||||
migrate: (persistedState, version) => migratePanelState(persistedState, version),
|
||||
partialize: (state) => ({
|
||||
@@ -295,6 +301,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
|
||||
treeRailWidth: state.treeRailWidth,
|
||||
fileTreeVisible: state.fileTreeVisible,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -137,6 +137,18 @@ describe("panel-store migration", () => {
|
||||
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
|
||||
});
|
||||
|
||||
it("narrows an existing install's tree rail to the new default width", () => {
|
||||
expect(DEFAULT_TREE_RAIL_WIDTH).toBeLessThan(320);
|
||||
expect(migratePanelState({ treeRailWidth: 320 }, 15).treeRailWidth).toBe(
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the file panel tree to open and keeps it closed once closed", () => {
|
||||
expect(migratePanelState({}, 15).fileTreeVisible).toBe(true);
|
||||
expect(migratePanelState({ fileTreeVisible: false }, 16).fileTreeVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("drops persisted compact panel state so cold starts return to content", () => {
|
||||
const state = migratePanelState(
|
||||
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
||||
|
||||
@@ -25,8 +25,8 @@ export const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
export const MIN_SIDEBAR_WIDTH = 200;
|
||||
export const MAX_SIDEBAR_WIDTH = 600;
|
||||
|
||||
export const DEFAULT_TREE_RAIL_WIDTH = 320;
|
||||
export const MIN_TREE_RAIL_WIDTH = 200;
|
||||
export const DEFAULT_TREE_RAIL_WIDTH = 260;
|
||||
export const MIN_TREE_RAIL_WIDTH = 180;
|
||||
export const MAX_TREE_RAIL_WIDTH = 600;
|
||||
|
||||
export interface PanelLayoutInput {
|
||||
@@ -151,6 +151,7 @@ export const PanelPersistedStateSchema = z.strictObject({
|
||||
explorerShowHiddenFiles: z.boolean().optional(),
|
||||
explorerFilesSplitRatio: z.number().optional(),
|
||||
treeRailWidth: z.number().optional(),
|
||||
fileTreeVisible: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
|
||||
@@ -193,8 +194,10 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
|
||||
}
|
||||
}
|
||||
|
||||
// v16 narrowed the rail. Existing installs almost all carry the old 320 default,
|
||||
// so the reset is what makes the narrower rail visible to anyone but a new user.
|
||||
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
|
||||
if (version < 13 || typeof state.treeRailWidth !== "number") {
|
||||
if (version < 16 || typeof state.treeRailWidth !== "number") {
|
||||
delete state.explorerFilesSplitRatio;
|
||||
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
|
||||
return;
|
||||
@@ -248,6 +251,9 @@ export function migratePanelState(persistedState: unknown, version: number): Mig
|
||||
state.explorerShowHiddenFiles = true;
|
||||
}
|
||||
migrateTreeRailWidth(state, version);
|
||||
if (typeof state.fileTreeVisible !== "boolean") {
|
||||
state.fileTreeVisible = true;
|
||||
}
|
||||
if (version < 12) {
|
||||
// Compact panel position is transient UI state. Cold starts always begin
|
||||
// at content, regardless of what an older version persisted.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -99,14 +101,40 @@ interface InsertSplitInternalResult {
|
||||
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;
|
||||
/** Explicit placement from a pane-local affordance. Wins over every other rule. */
|
||||
paneId?: string | null;
|
||||
/** Required so a new caller cannot silently opt out of the explorer placement rule. */
|
||||
explorerPaneId: string | null;
|
||||
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 {
|
||||
@@ -120,6 +148,11 @@ interface RetargetTabInLayoutInput {
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput {
|
||||
createTabId: () => string;
|
||||
state?: JsonValue;
|
||||
}
|
||||
|
||||
interface RetargetTabInLayoutResult {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -147,6 +180,11 @@ interface CloseTabInLayoutInput {
|
||||
preserveEmptyPaneId?: string | null;
|
||||
}
|
||||
|
||||
interface ClosePaneInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -202,7 +240,7 @@ export interface WorkspaceTabReconcileState {
|
||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||
pendingAgentIds?: ReadonlySet<string> | null;
|
||||
hiddenAgentIds?: ReadonlySet<string> | null;
|
||||
explorerPaneId: string | null;
|
||||
sidePanelPaneId: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabSnapshot {
|
||||
@@ -218,7 +256,8 @@ export interface WorkspaceTabSnapshot {
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
export const DEFAULT_EXPLORER_PANE_ID = "explorer";
|
||||
/** 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 {
|
||||
@@ -300,18 +339,15 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const tabId =
|
||||
target.kind === "working_diff"
|
||||
? buildDeterministicWorkspaceTabId(target)
|
||||
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -797,6 +833,7 @@ function replaceTabInTree(
|
||||
tabId: string;
|
||||
nextTabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
state?: JsonValue;
|
||||
},
|
||||
): SplitNodeInternal {
|
||||
const panePath = findPanePathContainingTab(root, input.tabId);
|
||||
@@ -807,15 +844,15 @@ function replaceTabInTree(
|
||||
kind: "pane",
|
||||
pane: normalizePaneAfterTabChange({
|
||||
...node.pane,
|
||||
tabs: node.pane.tabs.map((tab) =>
|
||||
tab.tabId === input.tabId
|
||||
? {
|
||||
...tab,
|
||||
tabId: input.nextTabId,
|
||||
target: input.target,
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
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,
|
||||
}),
|
||||
@@ -1076,14 +1113,14 @@ export function createDefaultLayout(): WorkspaceLayout {
|
||||
}
|
||||
|
||||
/** The desktop companion pane exists before it is first shown. */
|
||||
export function createWorkspaceLayoutWithExplorer(): WorkspaceLayout {
|
||||
export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout {
|
||||
return {
|
||||
root: createGroupNode({
|
||||
id: DEFAULT_LAYOUT_GROUP_ID,
|
||||
direction: "horizontal",
|
||||
children: [
|
||||
createPaneNode({ id: DEFAULT_PANE_ID }),
|
||||
createPaneNode({ id: DEFAULT_EXPLORER_PANE_ID, hidden: true }),
|
||||
createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }),
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
}),
|
||||
@@ -1125,10 +1162,10 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
||||
}).root;
|
||||
}
|
||||
|
||||
// The explorer pane is a background surface for the PR, files, and assistant output.
|
||||
// It can hold focus without the user ever having looked at it, so an ambient open of
|
||||
// something the user works *in* must not land there just because focus leaked.
|
||||
const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
|
||||
// Tab kinds the user works *in* rather than consults. Reconciliation opens these
|
||||
// with nobody behind the click, and the side panel can hold focus from an earlier
|
||||
// reveal, so an ambient open must not drop an agent there just because focus stayed.
|
||||
const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
|
||||
"agent",
|
||||
"provider_subagent",
|
||||
"terminal",
|
||||
@@ -1139,29 +1176,36 @@ const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new
|
||||
function resolvePlacementPane(input: {
|
||||
layout: { root: SplitNode; focusedPaneId: string | null };
|
||||
target: WorkspaceTabTarget;
|
||||
paneId: string | null | undefined;
|
||||
explorerPaneId: string | null;
|
||||
placement: WorkspaceTabPlacement;
|
||||
sidePanelPaneId: string | null;
|
||||
}): SplitPane {
|
||||
const requestedPane = findPaneById(input.layout.root, input.paneId);
|
||||
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 =
|
||||
findPaneById(input.layout.root, input.layout.focusedPaneId) ??
|
||||
(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 (
|
||||
focusedPane.id !== input.explorerPaneId ||
|
||||
!EXPLORER_EXCLUDED_TAB_KINDS.has(input.target.kind)
|
||||
input.placement.mode !== "ambient" ||
|
||||
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 explorer
|
||||
// pane itself only when it is the sole visible pane. The explorer is the only pane
|
||||
// anything ever hides, so today that means "the explorer is the only pane".
|
||||
// `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) ??
|
||||
@@ -1171,21 +1215,22 @@ function resolvePlacementPane(input: {
|
||||
}
|
||||
|
||||
function insertNewTabIntoPane(
|
||||
input: OpenTabInLayoutInput & { focus: boolean },
|
||||
input: CreateTabInLayoutInput & { focus: boolean },
|
||||
): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const targetPane = resolvePlacementPane({
|
||||
layout,
|
||||
target: input.target,
|
||||
paneId: input.paneId,
|
||||
explorerPaneId: input.explorerPaneId,
|
||||
placement: input.placement,
|
||||
sidePanelPaneId: input.sidePanelPaneId,
|
||||
});
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(input.target);
|
||||
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;
|
||||
@@ -1205,10 +1250,15 @@ function insertNewTabIntoPane(
|
||||
}
|
||||
|
||||
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
|
||||
const targetTabId = buildDeterministicWorkspaceTabId(target);
|
||||
const registration = getPanelRegistration(target.kind);
|
||||
const targetResourceKey = registration?.resourceKey(target as never);
|
||||
const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target);
|
||||
return (
|
||||
collectAllTabs(root).find(
|
||||
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target),
|
||||
(tab) =>
|
||||
tab.target.kind === target.kind &&
|
||||
(registration?.resourceKey(tab.target as never) ??
|
||||
buildDeterministicWorkspaceTabId(tab.target)) === targetIdentity,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -1227,6 +1277,7 @@ function updateExistingTabTarget(
|
||||
tabId: tab.tabId,
|
||||
nextTabId: tab.tabId,
|
||||
target,
|
||||
state: tab.state,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1238,17 +1289,70 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
|
||||
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? nextLayout,
|
||||
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);
|
||||
@@ -1259,7 +1363,11 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
|
||||
};
|
||||
}
|
||||
|
||||
return insertNewTabIntoPane({ ...input, focus: false });
|
||||
return insertNewTabIntoPane({
|
||||
...input,
|
||||
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||
focus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||
@@ -1270,7 +1378,7 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
||||
}
|
||||
const preserveEmptyPaneId =
|
||||
input.preserveEmptyPaneId ??
|
||||
(pane.id === DEFAULT_PANE_ID || pane.id === DEFAULT_EXPLORER_PANE_ID ? pane.id : null);
|
||||
(pane.id === DEFAULT_PANE_ID || pane.id === SIDE_PANEL_PANE_ID ? pane.id : null);
|
||||
|
||||
const closeSuccessorTabId = getCloseSuccessorTabId({
|
||||
pane,
|
||||
@@ -1313,6 +1421,57 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
||||
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);
|
||||
@@ -1395,6 +1554,107 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
/** 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();
|
||||
if (currentTab?.target.kind === "draft") {
|
||||
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 {
|
||||
@@ -1558,7 +1818,12 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
||||
|
||||
const detached = detachTabFromTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
preserveEmptyPaneId: sourcePane.id === input.toPaneId ? input.toPaneId : null,
|
||||
// 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;
|
||||
@@ -1606,6 +1871,10 @@ export function setPaneHiddenInLayout(input: {
|
||||
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;
|
||||
}
|
||||
@@ -1771,13 +2040,15 @@ function isTerminalTab(
|
||||
function openEntityTabWithoutFocusing(input: {
|
||||
layout: WorkspaceLayout;
|
||||
target: WorkspaceTabTarget;
|
||||
explorerPaneId: string | null;
|
||||
sidePanelPaneId: string | null;
|
||||
}): WorkspaceLayout {
|
||||
return insertNewTabIntoPane({
|
||||
layout: input.layout,
|
||||
target: input.target,
|
||||
now: Date.now(),
|
||||
explorerPaneId: input.explorerPaneId,
|
||||
placement: AMBIENT_PLACEMENT,
|
||||
sidePanelPaneId: input.sidePanelPaneId,
|
||||
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
|
||||
focus: false,
|
||||
}).layout;
|
||||
}
|
||||
@@ -1869,7 +2140,7 @@ function addMissingEntityTabs(input: {
|
||||
standaloneTerminalIds: Set<string>;
|
||||
hasActivePendingTerminalCreate: boolean;
|
||||
hasActivePendingDraftCreate: boolean;
|
||||
explorerPaneId: string | null;
|
||||
sidePanelPaneId: string | null;
|
||||
}): WorkspaceLayout {
|
||||
const {
|
||||
autoOpenAgentIds,
|
||||
@@ -1877,7 +2148,7 @@ function addMissingEntityTabs(input: {
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingTerminalCreate,
|
||||
hasActivePendingDraftCreate,
|
||||
explorerPaneId,
|
||||
sidePanelPaneId,
|
||||
} = input;
|
||||
let nextLayout = input.layout;
|
||||
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
||||
@@ -1899,7 +2170,7 @@ function addMissingEntityTabs(input: {
|
||||
nextLayout = openEntityTabWithoutFocusing({
|
||||
layout: nextLayout,
|
||||
target: { kind: "agent", agentId },
|
||||
explorerPaneId,
|
||||
sidePanelPaneId,
|
||||
});
|
||||
currentAgentIds.add(agentId);
|
||||
}
|
||||
@@ -1913,7 +2184,7 @@ function addMissingEntityTabs(input: {
|
||||
nextLayout = openEntityTabWithoutFocusing({
|
||||
layout: nextLayout,
|
||||
target: { kind: "terminal", terminalId },
|
||||
explorerPaneId,
|
||||
sidePanelPaneId,
|
||||
});
|
||||
currentTerminalIds.add(terminalId);
|
||||
}
|
||||
@@ -2006,7 +2277,7 @@ export function reconcileWorkspaceTabs(
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingTerminalCreate: snapshot.hasActivePendingTerminalCreate ?? false,
|
||||
hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false,
|
||||
explorerPaneId: state.explorerPaneId,
|
||||
sidePanelPaneId: state.sidePanelPaneId,
|
||||
});
|
||||
|
||||
if (reconciledFocusedTabId) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,26 @@ import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { z } from "zod";
|
||||
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,
|
||||
createWorkspaceLayoutWithExplorer,
|
||||
DEFAULT_EXPLORER_PANE_ID,
|
||||
AMBIENT_PLACEMENT,
|
||||
createWorkspaceLayoutWithSidePanel,
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
SIDE_PANEL_PANE_ID,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
focusPaneInLayout,
|
||||
@@ -27,20 +33,22 @@ import {
|
||||
moveTabToPaneInLayout,
|
||||
normalizeLayout,
|
||||
openTabInLayoutBackground,
|
||||
openTabInLayoutFocused,
|
||||
replaceTabTargetInLayout,
|
||||
revealTargetInLayout,
|
||||
reconcileWorkspaceTabs,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
reorderPaneTabsInLayout,
|
||||
retargetTabInLayout,
|
||||
setPaneHiddenInLayout,
|
||||
setTabStateInLayout,
|
||||
splitPaneEmptyInLayout,
|
||||
splitPaneInLayout,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
type WorkspaceTabPlacement,
|
||||
type WorkspaceTabReconcileState,
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
@@ -49,10 +57,13 @@ import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
export {
|
||||
AMBIENT_PLACEMENT,
|
||||
canDismissPaneInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createDefaultLayout,
|
||||
createWorkspaceLayoutWithExplorer,
|
||||
createWorkspaceLayoutWithSidePanel,
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
getFocusedBrowserId,
|
||||
@@ -68,10 +79,21 @@ export type {
|
||||
SplitNode,
|
||||
SplitPane,
|
||||
WorkspaceLayout,
|
||||
WorkspaceTabPlacement,
|
||||
WorkspaceTabReconcileState,
|
||||
WorkspaceTabSnapshot,
|
||||
};
|
||||
|
||||
export type WorkspaceTabOpenIntent = "new" | "reveal" | "background";
|
||||
export interface OpenWorkspaceTabInput {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: WorkspaceTabOpenIntent;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
parentTabId?: string;
|
||||
state?: JsonValue;
|
||||
}
|
||||
|
||||
interface WorkspaceLayoutStore {
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
@@ -79,43 +101,20 @@ interface WorkspaceLayoutStore {
|
||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
explorerPaneIdByWorkspace: Record<string, string | null>;
|
||||
acknowledgedPullRequestByWorkspace: Record<string, string>;
|
||||
openTabFocused: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
options?: WorkspaceTabOpenOptions,
|
||||
) => string | null;
|
||||
openTabInFocusedPane: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openChildTabFocused: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
parentTabId: string,
|
||||
options?: WorkspaceTabOpenOptions,
|
||||
) => string | null;
|
||||
openChildTabInFocusedPane: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
parentTabId: string,
|
||||
) => string | null;
|
||||
openTabInBackground: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
options?: WorkspaceTabOpenOptions,
|
||||
) => string | null;
|
||||
openTabInExplorerPaneBackground: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
) => string | null;
|
||||
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
|
||||
openTabInExplorerPaneFocused: (
|
||||
workspaceKey: string,
|
||||
input: { target: WorkspaceTabTarget; parentTabId?: string | null },
|
||||
) => string | null;
|
||||
observePullRequest: (workspaceKey: string, identity: string | null) => void;
|
||||
sidePanelPaneIdByWorkspace: Record<string, string | null>;
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
/** Reveals the side panel without putting anything in it. Returns its pane id. */
|
||||
showSidePanel: (workspaceKey: string) => string | null;
|
||||
hideSidePanel: (workspaceKey: string) => void;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
||||
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;
|
||||
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
|
||||
@@ -137,10 +136,13 @@ interface WorkspaceLayoutStore {
|
||||
},
|
||||
) => string | null;
|
||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||
/**
|
||||
* Dismisses the pane along with whatever it still holds. The side panel hides so
|
||||
* the user can bring it back; every other 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;
|
||||
hidePane: (workspaceKey: string, paneId: string) => void;
|
||||
showPane: (workspaceKey: string, paneId: string) => void;
|
||||
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
|
||||
unfocusPane: (workspaceKey: string) => string | null;
|
||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
@@ -152,22 +154,12 @@ interface WorkspaceLayoutStore {
|
||||
purgeWorkspace: (workspaceKey: string) => void;
|
||||
}
|
||||
|
||||
/** `paneId` is explicit placement for a pane-local affordance; omitted opens use policy. */
|
||||
export interface WorkspaceTabOpenOptions {
|
||||
paneId?: string | null;
|
||||
}
|
||||
|
||||
interface EnsureExplorerPaneResult {
|
||||
paneId: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
interface WorkspaceFocusRestorationState {
|
||||
restorePaneId: string | null;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
// The root companion split is always present for the hidden explorer pane.
|
||||
// The root companion split is always present for the hidden side panel pane.
|
||||
// Preserve four user-created split levels beneath it.
|
||||
const MAX_TREE_DEPTH = 5;
|
||||
|
||||
@@ -232,6 +224,7 @@ const WorkspaceTabStorageSchema = z.strictObject({
|
||||
tabId: z.string(),
|
||||
target: WorkspaceTabTargetStorageSchema,
|
||||
createdAt: z.number(),
|
||||
state: z.json().optional(),
|
||||
});
|
||||
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
|
||||
z.discriminatedUnion("kind", [
|
||||
@@ -264,7 +257,11 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
|
||||
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
||||
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
|
||||
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
|
||||
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
|
||||
// rename here would fail every existing blob and wipe the layout it describes.
|
||||
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
||||
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
|
||||
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
|
||||
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -276,6 +273,14 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
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,
|
||||
@@ -322,10 +327,33 @@ function getWorkspaceLayout(
|
||||
state: Record<string, WorkspaceLayout>,
|
||||
workspaceKey: string,
|
||||
): WorkspaceLayout {
|
||||
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorer());
|
||||
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithSidePanel());
|
||||
}
|
||||
|
||||
export function resolveExplorerPaneId(
|
||||
type SidePanelState = Pick<
|
||||
WorkspaceLayoutStore,
|
||||
"layoutByWorkspace" | "sidePanelPaneIdByWorkspace"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The side panel'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 selectSidePanelPaneId(state: SidePanelState, workspaceKey: string): string | null {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
||||
return resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]);
|
||||
}
|
||||
|
||||
/** Whether the side panel pane is currently on screen. */
|
||||
export function selectIsSidePanelVisible(state: SidePanelState, workspaceKey: string): boolean {
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
const paneId = layout ? selectSidePanelPaneId(state, workspaceKey) : null;
|
||||
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
||||
return Boolean(pane && pane.hidden !== true);
|
||||
}
|
||||
|
||||
export function resolveSidePanelPaneId(
|
||||
layout: WorkspaceLayout,
|
||||
registeredPaneId: string | null | undefined,
|
||||
): string | null {
|
||||
@@ -333,16 +361,16 @@ export function resolveExplorerPaneId(
|
||||
if (registeredPane) {
|
||||
return registeredPane.id;
|
||||
}
|
||||
const defaultPane = findPaneById(layout.root, DEFAULT_EXPLORER_PANE_ID);
|
||||
const defaultPane = findPaneById(layout.root, SIDE_PANEL_PANE_ID);
|
||||
return defaultPane?.id ?? null;
|
||||
}
|
||||
|
||||
function ensurePersistedExplorerPane(input: {
|
||||
function ensurePersistedSidePanelPane(input: {
|
||||
layout: WorkspaceLayout;
|
||||
registeredPaneId: string | null | undefined;
|
||||
ids: WorkspaceLayoutIdSource;
|
||||
}): { layout: WorkspaceLayout; paneId: string } | null {
|
||||
const existingPaneId = resolveExplorerPaneId(input.layout, input.registeredPaneId);
|
||||
const existingPaneId = resolveSidePanelPaneId(input.layout, input.registeredPaneId);
|
||||
if (existingPaneId) {
|
||||
return { layout: input.layout, paneId: existingPaneId };
|
||||
}
|
||||
@@ -373,13 +401,17 @@ function ensurePersistedExplorerPane(input: {
|
||||
function getOpenTabPlacement(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
options: WorkspaceTabOpenOptions | undefined,
|
||||
): { layout: WorkspaceLayout; paneId: string | null; explorerPaneId: string | null } {
|
||||
placement: WorkspaceTabPlacement | undefined,
|
||||
): {
|
||||
layout: WorkspaceLayout;
|
||||
placement: WorkspaceTabPlacement;
|
||||
sidePanelPaneId: string | null;
|
||||
} {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
||||
return {
|
||||
layout,
|
||||
paneId: trimNonEmpty(options?.paneId),
|
||||
explorerPaneId: resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]),
|
||||
placement: placement ?? AMBIENT_PLACEMENT,
|
||||
sidePanelPaneId: resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -420,6 +452,20 @@ function attachParentTab(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a side panel out of the pane the user is in. Only reached by layouts saved
|
||||
* before the side panel became part of the default tree; new ones are born with it.
|
||||
*/
|
||||
function createSidePanelPane(
|
||||
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,
|
||||
) {
|
||||
@@ -432,21 +478,37 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
acknowledgedPullRequestByWorkspace: {},
|
||||
openTabFocused: (workspaceKey, target, options) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
sidePanelPaneIdByWorkspace: {},
|
||||
openTab: (input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openTabInLayoutFocused({
|
||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
const placement = getOpenTabPlacement(get(), normalizedWorkspaceKey, input.placement);
|
||||
let result;
|
||||
if (input.intent === "new") {
|
||||
result = createTabInLayout({
|
||||
...placement,
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
createTabId: createWorkspaceTabInstanceId,
|
||||
state: input.state,
|
||||
});
|
||||
} else if (input.intent === "background") {
|
||||
result = openTabInLayoutBackground({
|
||||
...placement,
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
} else {
|
||||
result = revealTargetInLayout({
|
||||
...placement,
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
createTabId: createWorkspaceTabInstanceId,
|
||||
});
|
||||
}
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
@@ -459,244 +521,73 @@ export function createWorkspaceLayoutStore(
|
||||
),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
[normalizedWorkspaceKey]: input.parentTabId
|
||||
? attachParentTab({
|
||||
layout: result.layout,
|
||||
childTabId: result.tabId,
|
||||
parentTabId: input.parentTabId,
|
||||
})
|
||||
: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openTabInFocusedPane: (workspaceKey, target) => {
|
||||
showSidePanel: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
||||
return get().openTabFocused(normalizedWorkspaceKey, target, {
|
||||
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
|
||||
});
|
||||
},
|
||||
openChildTabFocused: (workspaceKey, target, parentTabId, options) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedParentTabId = trimNonEmpty(parentTabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedParentTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openTabInLayoutFocused({
|
||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
const layout = attachParentTab({
|
||||
layout: result.layout,
|
||||
childTabId: result.tabId,
|
||||
parentTabId: normalizedParentTabId,
|
||||
});
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openChildTabInFocusedPane: (workspaceKey, target, parentTabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
||||
return get().openChildTabFocused(normalizedWorkspaceKey, target, parentTabId, {
|
||||
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
|
||||
});
|
||||
},
|
||||
openTabInBackground: (workspaceKey, target, options) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openTabInLayoutBackground({
|
||||
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openTabInExplorerPaneBackground: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tabId: string | null = null;
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerPaneId = resolveExplorerPaneId(
|
||||
const paneId =
|
||||
resolveSidePanelPaneId(
|
||||
layout,
|
||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
);
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
if (!explorerPane) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// A new tab is placed straight into the explorer pane, hidden or not.
|
||||
// A tab already open elsewhere stays where the user put it — this open
|
||||
// must not move, reveal, or focus anything.
|
||||
const result = openTabInLayoutBackground({
|
||||
layout,
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
paneId: explorerPane.id,
|
||||
explorerPaneId: explorerPane.id,
|
||||
});
|
||||
|
||||
tabId = result.tabId;
|
||||
return {
|
||||
explorerPaneIdByWorkspace: {
|
||||
...state.explorerPaneIdByWorkspace,
|
||||
[normalizedWorkspaceKey]: explorerPane.id,
|
||||
},
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
return tabId;
|
||||
},
|
||||
ensureExplorerPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = get();
|
||||
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerPaneId = resolveExplorerPaneId(
|
||||
layout,
|
||||
store.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
);
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
if (explorerPane) {
|
||||
if (store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] !== explorerPane.id) {
|
||||
store.setExplorerPaneId(normalizedWorkspaceKey, explorerPane.id);
|
||||
}
|
||||
store.focusPane(normalizedWorkspaceKey, explorerPane.id);
|
||||
return { paneId: explorerPane.id, created: false };
|
||||
}
|
||||
|
||||
const targetPaneId =
|
||||
findPaneById(layout.root, layout.focusedPaneId)?.id ??
|
||||
collectAllPanes(layout.root)[0]?.id;
|
||||
if (!targetPaneId) {
|
||||
return null;
|
||||
}
|
||||
const paneId = store.splitPaneEmpty(normalizedWorkspaceKey, {
|
||||
targetPaneId,
|
||||
position: "right",
|
||||
});
|
||||
get().sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
) ?? createSidePanelPane(normalizedWorkspaceKey, layout, get().splitPaneEmpty);
|
||||
if (!paneId) {
|
||||
return null;
|
||||
}
|
||||
get().setExplorerPaneId(normalizedWorkspaceKey, paneId);
|
||||
return { paneId, created: true };
|
||||
},
|
||||
openTabInExplorerPaneFocused: (workspaceKey, input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = get();
|
||||
const explorerPane = store.ensureExplorerPane(normalizedWorkspaceKey);
|
||||
if (!explorerPane) {
|
||||
return null;
|
||||
}
|
||||
const parentTabId = trimNonEmpty(input.parentTabId);
|
||||
const placement = { paneId: explorerPane.paneId };
|
||||
const tabId = parentTabId
|
||||
? get().openChildTabFocused(
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget,
|
||||
parentTabId,
|
||||
placement,
|
||||
)
|
||||
: get().openTabFocused(normalizedWorkspaceKey, normalizedTarget, placement);
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const currentPane = findPaneContainingTab(layout.root, tabId);
|
||||
if (currentPane?.id !== explorerPane.paneId) {
|
||||
get().moveTabToPane(normalizedWorkspaceKey, tabId, explorerPane.paneId);
|
||||
}
|
||||
return tabId;
|
||||
set((state) =>
|
||||
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey] === paneId
|
||||
? state
|
||||
: {
|
||||
sidePanelPaneIdByWorkspace: {
|
||||
...state.sidePanelPaneIdByWorkspace,
|
||||
[normalizedWorkspaceKey]: paneId,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Focusing a hidden pane reveals it, so this is both halves of "show".
|
||||
get().focusPane(normalizedWorkspaceKey, paneId);
|
||||
return paneId;
|
||||
},
|
||||
observePullRequest: (workspaceKey, identity) => {
|
||||
hideSidePanel: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedIdentity = trimNonEmpty(identity);
|
||||
if (!normalizedWorkspaceKey || !normalizedIdentity) {
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
get().acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] === normalizedIdentity
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A detected pull request is a background add: the tab appears in the
|
||||
// explorer pane, but nothing is revealed and focus never moves.
|
||||
const tabId = get().openTabInExplorerPaneBackground(normalizedWorkspaceKey, {
|
||||
kind: "pull_request",
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const paneId = resolveSidePanelPaneId(
|
||||
layout,
|
||||
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
);
|
||||
const nextLayout = paneId
|
||||
? setPaneHiddenInLayout({ layout, paneId, hidden: true })
|
||||
: null;
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
acknowledgedPullRequestByWorkspace: {
|
||||
...state.acknowledgedPullRequestByWorkspace,
|
||||
[normalizedWorkspaceKey]: normalizedIdentity,
|
||||
},
|
||||
}));
|
||||
},
|
||||
closeTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
@@ -707,20 +598,28 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerPaneId = resolveExplorerPaneId(
|
||||
const sidePanelPaneId = resolveSidePanelPaneId(
|
||||
layout,
|
||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
);
|
||||
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
|
||||
const preserveEmptyPaneId =
|
||||
closingPane?.id === "main" || closingPane?.id === explorerPaneId
|
||||
closingPane?.id === "main" || closingPane?.id === sidePanelPaneId
|
||||
? closingPane.id
|
||||
: null;
|
||||
const nextLayout = closeTabInLayout({
|
||||
const closedLayout = closeTabInLayout({
|
||||
layout,
|
||||
tabId: normalizedTabId,
|
||||
preserveEmptyPaneId,
|
||||
});
|
||||
const nextLayout =
|
||||
closedLayout && closingPane?.id === sidePanelPaneId && closingPane.tabIds.length === 1
|
||||
? (setPaneHiddenInLayout({
|
||||
layout: closedLayout,
|
||||
paneId: sidePanelPaneId,
|
||||
hidden: true,
|
||||
}) ?? closedLayout)
|
||||
: closedLayout;
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
@@ -759,27 +658,21 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
retargetTab: (workspaceKey, tabId, target) => {
|
||||
replaceTab: (workspaceKey, tabId, target, tabState) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = retargetTabInLayout({
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
||||
const result = replaceTabTargetInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createTabId: createWorkspaceTabInstanceId,
|
||||
state: tabState,
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!result) return null;
|
||||
set((state) => ({
|
||||
...(result.layout.focusedPaneId !== null
|
||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||
: {}),
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -793,9 +686,27 @@ export function createWorkspaceLayoutStore(
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
setTabState: (workspaceKey, tabId, tabState) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
||||
set((state) => {
|
||||
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);
|
||||
@@ -847,9 +758,9 @@ export function createWorkspaceLayoutStore(
|
||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
explorerPaneId: resolveExplorerPaneId(
|
||||
sidePanelPaneId: resolveSidePanelPaneId(
|
||||
currentLayout,
|
||||
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
),
|
||||
},
|
||||
snapshot,
|
||||
@@ -1002,6 +913,38 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
closePane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
// The side panel is a surface the user summons, so closing it puts it
|
||||
// away rather than dismantling the split it lives in.
|
||||
const isSidePanel =
|
||||
resolveSidePanelPaneId(
|
||||
layout,
|
||||
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
) === normalizedPaneId;
|
||||
const nextLayout = isSidePanel
|
||||
? setPaneHiddenInLayout({ layout, paneId: normalizedPaneId, hidden: true })
|
||||
: closePaneInLayout({ layout, paneId: normalizedPaneId });
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
focusPane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
@@ -1027,67 +970,6 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
hidePane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = setPaneHiddenInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
hidden: true,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
showPane: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedPaneId = trimNonEmpty(paneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextLayout = setPaneHiddenInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
paneId: normalizedPaneId,
|
||||
hidden: false,
|
||||
});
|
||||
if (!nextLayout) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
setExplorerPaneId: (workspaceKey, paneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
explorerPaneIdByWorkspace: {
|
||||
...state.explorerPaneIdByWorkspace,
|
||||
[normalizedWorkspaceKey]: trimNonEmpty(paneId),
|
||||
},
|
||||
}));
|
||||
},
|
||||
unfocusPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
@@ -1359,8 +1241,7 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
|
||||
normalizedWorkspaceKey in state.sidePanelPaneIdByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -1376,12 +1257,8 @@ export function createWorkspaceLayoutStore(
|
||||
state.hiddenAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
|
||||
state.explorerPaneIdByWorkspace;
|
||||
const {
|
||||
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
|
||||
...acknowledgedPullRequestByWorkspace
|
||||
} = state.acknowledgedPullRequestByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _sidePanelPane, ...sidePanelPaneIdByWorkspace } =
|
||||
state.sidePanelPaneIdByWorkspace;
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -1389,8 +1266,7 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace,
|
||||
hiddenAgentIdsByWorkspace,
|
||||
focusRestorationByWorkspace,
|
||||
explorerPaneIdByWorkspace,
|
||||
acknowledgedPullRequestByWorkspace,
|
||||
sidePanelPaneIdByWorkspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -1411,8 +1287,7 @@ export function createWorkspaceLayoutStore(
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
|
||||
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
|
||||
explorerPaneIdByWorkspace: state.sidePanelPaneIdByWorkspace,
|
||||
};
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -1421,31 +1296,29 @@ export function createWorkspaceLayoutStore(
|
||||
return currentState;
|
||||
}
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
const explorerPaneIdByWorkspace: Record<string, string | null> = {
|
||||
const sidePanelPaneIdByWorkspace: Record<string, string | null> = {
|
||||
...result.data.explorerPaneIdByWorkspace,
|
||||
};
|
||||
for (const [workspaceKey, persistedLayout] of Object.entries(
|
||||
result.data.layoutByWorkspace,
|
||||
)) {
|
||||
const explorer = ensurePersistedExplorerPane({
|
||||
const sidePanel = ensurePersistedSidePanelPane({
|
||||
layout: normalizeLayout(persistedLayout),
|
||||
registeredPaneId: explorerPaneIdByWorkspace[workspaceKey],
|
||||
registeredPaneId: sidePanelPaneIdByWorkspace[workspaceKey],
|
||||
ids,
|
||||
});
|
||||
if (!explorer) {
|
||||
if (!sidePanel) {
|
||||
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
|
||||
continue;
|
||||
}
|
||||
layoutByWorkspace[workspaceKey] = explorer.layout;
|
||||
explorerPaneIdByWorkspace[workspaceKey] = explorer.paneId;
|
||||
layoutByWorkspace[workspaceKey] = sidePanel.layout;
|
||||
sidePanelPaneIdByWorkspace[workspaceKey] = sidePanel.paneId;
|
||||
}
|
||||
return {
|
||||
...currentState,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
||||
explorerPaneIdByWorkspace,
|
||||
acknowledgedPullRequestByWorkspace:
|
||||
result.data.acknowledgedPullRequestByWorkspace ?? {},
|
||||
sidePanelPaneIdByWorkspace,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("workspace subagents integration", () => {
|
||||
).toEqual([child.id]);
|
||||
});
|
||||
|
||||
it("opens a subagent in the explorer pane when focused", () => {
|
||||
it("opens a subagent in the side panel when focused", () => {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
@@ -275,22 +275,26 @@ describe("workspace subagents integration", () => {
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
|
||||
const paneId = store.showSidePanel(workspaceKey!) as string;
|
||||
const tabId = store.openTab({
|
||||
workspaceKey: workspaceKey!,
|
||||
target: { kind: "agent", agentId: child.id },
|
||||
intent: "reveal",
|
||||
parentTabId: `agent_${parent.id}`,
|
||||
placement: { mode: "prefer", paneId },
|
||||
});
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey!];
|
||||
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
|
||||
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
|
||||
|
||||
expect(explorerPaneId).toBeTruthy();
|
||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
|
||||
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
|
||||
expect(layout.focusedPaneId).toBe(explorerPaneId);
|
||||
expect(sidePanelPaneId).toBeTruthy();
|
||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
|
||||
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
|
||||
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
|
||||
});
|
||||
|
||||
it("opens a provider subagent in the explorer pane when focused", () => {
|
||||
it("opens a provider subagent in the side panel when focused", () => {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
@@ -298,18 +302,22 @@ describe("workspace subagents integration", () => {
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
|
||||
const paneId = store.showSidePanel(workspaceKey!) as string;
|
||||
const tabId = store.openTab({
|
||||
workspaceKey: workspaceKey!,
|
||||
target: { kind: "provider_subagent", parentAgentId: "parent-agent", subagentId: "task-1" },
|
||||
intent: "reveal",
|
||||
parentTabId: "agent_parent-agent",
|
||||
placement: { mode: "prefer", paneId },
|
||||
});
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey!];
|
||||
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
|
||||
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
|
||||
|
||||
expect(explorerPaneId).toBeTruthy();
|
||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
|
||||
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
|
||||
expect(layout.focusedPaneId).toBe(explorerPaneId);
|
||||
expect(sidePanelPaneId).toBeTruthy();
|
||||
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
|
||||
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
|
||||
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user