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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user