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:
Mohamed Boudra
2026-08-20 21:49:23 +02:00
committed by GitHub
parent 2a0a0ff656
commit 19a7d26349
107 changed files with 5386 additions and 2178 deletions
@@ -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",
});
}
}
+8 -1
View File
@@ -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 } },
+9 -3
View File
@@ -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
+268 -395
View File
@@ -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);
});
});