Files
kandev/apps/web/lib/state/dockview-store.ts
T

914 lines
34 KiB
TypeScript

/* eslint-disable max-lines -- zustand god-store; splitting is a separate refactor. */
import { create } from "zustand";
import type { DockviewApi, AddPanelOptions, SerializedDockview } from "dockview-react";
import {
setEnvLayout,
getEnvMaximizeState,
setEnvMaximizeState,
removeEnvMaximizeState,
} from "@/lib/local-storage";
import { setPinnedTarget } from "./layout-manager";
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
import {
SIDEBAR_GROUP,
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
TERMINAL_DEFAULT_ID,
getPresetLayout,
getPresetSidebarColumn,
applyLayout,
getRootSplitview,
fromDockviewApi,
filterEphemeral,
defaultLayout,
mergeCurrentPanelsIntoPreset,
toSerializedDockview,
} from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager";
import { performEnvSwitch } from "./dockview-env-switch";
import { enforcePinnedTargets } from "./dockview-pinned-enforce";
import {
injectIntentPanels,
applyActivePanelOverrides,
resolveNamedIntent,
} from "./layout-manager";
import { buildFileStateActions } from "./dockview-file-state";
import {
buildPanelActions,
buildExtraPanelActions,
type OpenPanelOpts,
type PreviewType,
} from "./dockview-panel-actions";
import { preserveChatScrollDuringLayout } from "./dockview-scroll-preserve";
import { measureDockviewContainer } from "./dockview-measure";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "./dockview-widths-debug";
const debugSwitch = createDebugLogger("dockview:store-switch");
const debugSave = createDebugLogger("dockview:save");
const debugWidths = createDebugLogger("dockview:widths");
const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]);
// Re-export types and constants used by other modules
export type { BuiltInPreset } from "./layout-manager";
export {
LAYOUT_SIDEBAR_RATIO,
LAYOUT_RIGHT_RATIO,
computeSidebarMaxPx,
computeRightMaxPx,
LAYOUT_PINNED_MIN_PX,
} from "./layout-manager";
export { applyLayoutFixups } from "./dockview-layout-builders";
export type FileEditorState = {
path: string;
name: string;
content: string;
originalContent: string;
originalHash: string;
isDirty: boolean;
isBinary?: boolean;
resolvedPath?: string;
hasRemoteUpdate?: boolean;
remoteContent?: string;
remoteOriginalHash?: string;
markdownPreview?: boolean;
};
/** Direction relative to a reference panel or group. */
export type PanelDirection = "left" | "right" | "above" | "below";
/** A deferred panel operation applied after the next layout build / restore. */
export type DeferredPanelAction = {
id: string;
component: string;
title: string;
placement: "tab" | PanelDirection;
referencePanel?: string;
params?: Record<string, unknown>;
};
/** Saved layout configuration persisted to user settings. */
export type SavedLayoutConfig = {
id: string;
name: string;
isDefault: boolean;
layout: Record<string, unknown>;
createdAt: string;
};
type DockviewStore = {
api: DockviewApi | null;
setApi: (api: DockviewApi | null) => void;
openFiles: Map<string, FileEditorState>;
setFileState: (path: string, state: FileEditorState) => void;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
removeFileState: (path: string) => void;
clearFileStates: () => void;
buildDefaultLayout: (api: DockviewApi, intentName?: string) => void;
resetLayout: () => void;
addChatPanel: () => void;
addChangesPanel: (groupId?: string) => void;
addFilesPanel: (groupId?: string) => void;
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => void;
addFileDiffPanel: (
path: string,
opts?: OpenPanelOpts & {
content?: string;
groupId?: string;
source?: string;
repositoryName?: string;
},
) => void;
addCommitDetailPanel: (
sha: string,
opts?: OpenPanelOpts & { groupId?: string; repo?: string },
) => void;
addFileEditorPanel: (path: string, name: string, opts?: OpenPanelOpts) => void;
promotePreviewToPinned: (type: PreviewType) => void;
addBrowserPanel: (url?: string, groupId?: string) => void;
addVscodePanel: () => void;
openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void;
addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => void;
/** Open a PR detail panel. prKey (owner/repo/pr_number) gives multi-repo tasks one tab per PR.
* activeSessionId anchors the new panel to the session's current group so it lands as a tab
* next to the session, not as a split. Falls back to centerGroupId when omitted. */
addPRPanel: (prKey?: string, activeSessionId?: string | null) => void;
addTerminalPanel: (
terminalId?: string,
groupId?: string,
environmentId?: string,
taskID?: string,
title?: string,
) => void;
selectedDiff: { path: string; content?: string } | null;
setSelectedDiff: (diff: { path: string; content?: string } | null) => void;
activeGroupId: string | null;
centerGroupId: string;
rightTopGroupId: string;
rightBottomGroupId: string;
sidebarGroupId: string;
sidebarVisible: boolean;
rightPanelsVisible: boolean;
toggleSidebar: () => void;
toggleRightPanels: () => void;
setSidebarVisible: (visible: boolean) => void;
setRightPanelsVisible: (visible: boolean) => void;
applyBuiltInPreset: (preset: BuiltInPreset) => void;
defaultPreset: BuiltInPreset;
setDefaultPreset: (preset: BuiltInPreset) => void;
applyCustomLayout: (layout: SavedLayoutConfig) => void;
captureCurrentLayout: () => Record<string, unknown>;
isRestoringLayout: boolean;
/** ID of the task environment whose layout is currently rendered. Layouts are
* keyed by env so sessions sharing an env reuse one layout. */
currentLayoutEnvId: string | null;
/** Switch the rendered layout to a new task environment. Same-env switches
* are a no-op (the layout already belongs to that env). `activeSessionId`
* is the session whose chat panel should be present in the new env. */
switchEnvLayout: (
oldEnvId: string | null,
newEnvId: string,
activeSessionId: string | null,
) => void;
deferredPanelActions: DeferredPanelAction[];
queuePanelAction: (action: DeferredPanelAction) => void;
pinnedWidths: Map<string, number>;
setPinnedWidth: (columnId: string, width: number) => void;
userDefaultLayout: LayoutState | null;
setUserDefaultLayout: (layout: LayoutState | null) => void;
activeFilePath: string | null;
pendingChatScrollTop: number | null;
setPendingChatScrollTop: (value: number | null) => void;
/** Saved layout from before a manual maximize. Null when not maximized. */
preMaximizeLayout: LayoutState | null;
/** The group ID that was maximized (used for session restore). */
maximizedGroupId: string | null;
maximizeGroup: (groupId: string) => void;
exitMaximizedLayout: () => void;
};
type StoreGet = () => DockviewStore;
type StoreSet = (
partial: Partial<DockviewStore> | ((s: DockviewStore) => Partial<DockviewStore>),
) => void;
function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelAction[]): void {
for (const action of actions) {
const ref = action.referencePanel ?? "chat";
let position: AddPanelOptions["position"];
if (action.placement === "tab") {
const groupId = api.getPanel(ref)?.group?.id;
if (groupId) position = { referenceGroup: groupId };
} else {
position = { referencePanel: ref, direction: action.placement };
}
focusOrAddPanel(api, {
id: action.id,
component: action.component,
title: action.title,
position,
...(action.params ? { params: action.params } : {}),
});
}
}
/** Read live column widths from dockview's splitview and persist them as pinned overrides.
* Only syncs widths for columns identified as "sidebar" or "right" to avoid
* capturing plan/preview/vscode column widths as stale "right" overrides. */
function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
if (api.hasMaximizedGroup()) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
const state = fromDockviewApi(api);
if (state.columns.length !== sv.length) return;
const updates = new Map<string, number>();
for (let i = 0; i < state.columns.length; i++) {
const col = state.columns[i];
if (col.id === "sidebar" || col.id === "right") {
const w = sv.getViewSize(i);
if (w > 50) updates.set(col.id, w);
}
}
if (updates.size > 0) {
if (IS_DEBUG) {
const pairs = Array.from(updates.entries())
.map(([k, v]) => `${k}=${Math.round(v)}`)
.join(",");
debugWidths(`store-sync ${pairs} ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
set((prev) => {
const m = new Map(prev.pinnedWidths);
for (const [k, v] of updates) m.set(k, v);
return { pinnedWidths: m };
});
}
} catch {
/* noop */
}
}
/** Capture the live sidebar/right pixel widths into pinnedWidths before a layout rebuild. */
function captureLiveWidths(api: DockviewApi, set: StoreSet): Map<string, number> {
if (api.hasMaximizedGroup()) {
api.exitMaximizedGroup();
}
syncPinnedWidthsFromApi(api, set);
return useDockviewStore.getState().pinnedWidths;
}
/**
* Snap pinned columns to their targets using the current store state.
*
* Called inside every programmatic layout path's post-`api.layout` rAF
* before flipping `isRestoringLayout` false - dockview's proportional
* rebalance can grow pinned columns up to their loose `setConstraints` max,
* and the reactive enforcement (wired via `onDidLayoutChange`) is gated by
* `isRestoringLayout`, so without this synchronous call the correction
* would only land on the next user-triggered layout-change event,
* producing a visible jerk once env prep settles.
*/
function enforceFromStore(api: DockviewApi, get: StoreGet): void {
const s = get();
enforcePinnedTargets(api, {
sidebarVisible: s.sidebarVisible,
rightPanelsVisible: s.rightPanelsVisible,
maximized: s.preMaximizeLayout !== null,
});
}
function applyLayoutAndSet(
api: DockviewApi,
state: LayoutState,
pinnedWidths: Map<string, number>,
set: StoreSet,
): LayoutGroupIds {
// Pass measured container dims so fromJSON's grid.width matches the live
// container — avoids the proportional rescale that would otherwise grow
// pinned columns past their legacy initial caps on the next api.layout.
const measured = measureDockviewContainer(api);
const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height);
set(ids);
return ids;
}
function removeRightPanelTabs(state: LayoutState): LayoutState {
const columns = state.columns
.map((col) => {
const groups = col.groups
.map((group) => {
const panels = group.panels.filter((panel) => !RIGHT_PANEL_IDS.has(panel.id));
if (panels.length === group.panels.length) return group;
const activePanel = panels.some((panel) => panel.id === group.activePanel)
? group.activePanel
: panels[0]?.id;
return { ...group, panels, activePanel };
})
.filter((group) => group.panels.length > 0);
return { ...col, groups };
})
.filter((col) => col.groups.length > 0);
return { columns };
}
function buildVisibilityActions(set: StoreSet, get: StoreGet) {
return {
toggleSidebar: () => {
const { api, sidebarVisible } = get();
if (!api) return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
if (sidebarVisible) {
const current = fromDockviewApi(api);
const withoutSidebar: LayoutState = {
columns: current.columns.filter((c) => c.id !== "sidebar"),
};
set({ isRestoringLayout: true, sidebarVisible: false });
applyLayoutAndSet(api, withoutSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
} else {
const current = fromDockviewApi(api);
const sidebarCol = getPresetSidebarColumn(get().defaultPreset);
const withSidebar: LayoutState = {
columns: [sidebarCol, ...current.columns],
};
set({ isRestoringLayout: true, sidebarVisible: true });
applyLayoutAndSet(api, withSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
}
},
toggleRightPanels: () => {
const { api, rightPanelsVisible, defaultPreset } = get();
if (!api) return;
if (!rightPanelsVisible && defaultPreset === "compact") return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
if (rightPanelsVisible) {
const current = fromDockviewApi(api);
const withoutRight: LayoutState = {
columns: current.columns.filter(
(c) =>
!c.groups.some((g) => g.panels.some((p) => p.id === "files" || p.id === "changes")),
),
};
set({ isRestoringLayout: true, rightPanelsVisible: false });
applyLayoutAndSet(api, withoutRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
} else {
const defLayout = defaultLayout();
const rightCol = defLayout.columns.find((c) => c.id === "right");
if (!rightCol) return;
const current = removeRightPanelTabs(fromDockviewApi(api));
const withRight: LayoutState = {
columns: [...current.columns, rightCol],
};
set({ isRestoringLayout: true, rightPanelsVisible: true });
applyLayoutAndSet(api, withRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
}
},
setSidebarVisible: (visible: boolean) => {
const { sidebarVisible } = get();
if (sidebarVisible === visible) return;
get().toggleSidebar();
},
setRightPanelsVisible: (visible: boolean) => {
const { rightPanelsVisible } = get();
if (rightPanelsVisible === visible) return;
get().toggleRightPanels();
},
};
}
function buildPresetActions(set: StoreSet, get: StoreGet) {
return {
applyBuiltInPreset: (preset: BuiltInPreset) => {
const { api } = get();
if (!api) return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
// Capture dimensions before layout change — api.width can become stale
// inside the rAF callback after dockview serialization
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({ isRestoringLayout: true });
const presetState = getPresetLayout(preset);
const state = mergeCurrentPanelsIntoPreset(api, presetState);
// Remove stale pinned overrides for columns absent in the target layout
const targetColumnIds = new Set(state.columns.map((c) => c.id));
const cleanedWidths = new Map(liveWidths);
for (const key of cleanedWidths.keys()) {
if (!targetColumnIds.has(key)) cleanedWidths.delete(key);
}
const ids = applyLayout(api, state, cleanedWidths, safeWidth, safeHeight);
set({
...ids,
sidebarVisible: true,
rightPanelsVisible: preset === "default",
pinnedWidths: cleanedWidths,
});
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
},
applyCustomLayout: (layout: SavedLayoutConfig) => {
const { api } = get();
if (!api) return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({ isRestoringLayout: true });
const state = layout.layout as unknown as LayoutState;
if (!state?.columns) {
try {
api.fromJSON(layout.layout as unknown as SerializedDockview);
set(applyLayoutFixups(api));
} catch (e) {
console.warn("applyCustomLayout: old-format restore failed:", e);
}
} else {
const ids = applyLayout(api, state, liveWidths, safeWidth, safeHeight);
set(ids);
}
const hasSidebar = !!api.getPanel("sidebar");
const colCount = state?.columns?.length ?? api.groups.length;
const sidebarCols = hasSidebar ? 1 : 0;
const hasRight = colCount > sidebarCols + 1;
set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
},
captureCurrentLayout: (): Record<string, unknown> => {
const { api } = get();
if (!api) return {};
const state = fromDockviewApi(api);
const filtered = filterEphemeral(state);
return filtered as unknown as Record<string, unknown>;
},
};
}
/** Restore a saved maximize state from sessionStorage onto the dockview API. */
function restoreMaximizeFromStorage(api: DockviewApi, envId: string, set: StoreSet): boolean {
const saved = getEnvMaximizeState(envId);
if (!saved) return false;
try {
api.fromJSON(saved.maximizedDockviewJson as SerializedDockview);
// After fromJSON, `api.width/height` reflect the JSON's recorded grid
// dims, which may not match the live container. Always lay out against
// the measured DOM size so a stale value can't pin the dockview at the
// wrong width on subsequent restores.
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
const ids = applyLayoutFixups(api);
const preMax = saved.preMaximizeLayout as unknown as LayoutState;
// The maximized layout is `[sidebar?, maximized]` — the non-sidebar group
// is the one being maximized, which `resolveGroupIds` returns as
// `centerGroupId`. Tracking it keeps the store consistent with what
// `maximizeGroup` would have set (so toggle/exit logic doesn't operate on
// a half-restored maximize).
set({ ...ids, preMaximizeLayout: preMax, maximizedGroupId: ids.centerGroupId });
} catch {
// Drop the bad blob so the next switch/reload doesn't keep reattempting
// the same failing fromJSON before falling back. Self-healing.
removeEnvMaximizeState(envId);
return false;
}
requestAnimationFrame(() => {
set({ isRestoringLayout: false });
});
return true;
}
/** Save the outgoing env's layout & maximize state, then release its portals. */
function saveOutgoingEnv(
api: DockviewApi,
oldEnvId: string | null,
preMaximizeLayout: LayoutState | null,
pinnedWidths: Map<string, number>,
): void {
if (!oldEnvId) {
debugSave("saveOutgoingEnv: skip (no oldEnvId)");
return;
}
if (IS_DEBUG) {
debugSave("saveOutgoingEnv: entry", {
oldEnvId,
livePanelIds: api.panels.map((p) => p.id),
maximized: !!preMaximizeLayout,
});
}
if (preMaximizeLayout) {
// While maximized, `api.toJSON()` is the 2-column maximize overlay, NOT
// the user's intended layout. Persist the pre-max layout under both keys:
// - max state: maximizedDockviewJson is what the user sees (the overlay);
// - env layout: pre-max serialized so a reload that misses the max state
// (e.g. cleared maximize) falls back to the user's real layout, not a
// truncated 2-column slice.
// Wrapped in try/catch so a serialization throw can't skip releaseByEnv at
// the bottom (which would re-leak env-scoped portals).
try {
setEnvMaximizeState(oldEnvId, {
preMaximizeLayout: preMaximizeLayout as unknown as object,
maximizedDockviewJson: api.toJSON(),
});
} catch (err) {
removeEnvMaximizeState(oldEnvId);
console.warn("saveOutgoingEnv: failed to persist maximize state", err);
}
try {
// Use measured container size — `api.width/height` can be drifted from
// the live container, and serializing with stale dims would persist a
// shrunken layout that resurfaces on the next reload.
const { width, height } = measureDockviewContainer(api);
const preMaxSerialized = toSerializedDockview(preMaximizeLayout, width, height, pinnedWidths);
setEnvLayout(oldEnvId, preMaxSerialized as unknown as object);
} catch (err) {
console.warn("saveOutgoingEnv: serialize failed", err);
/* fall back: skip writing rather than overwrite with maximized JSON */
}
} else {
removeEnvMaximizeState(oldEnvId);
try {
setEnvLayout(oldEnvId, api.toJSON());
} catch {
/* ignore */
}
}
panelPortalManager.releaseByEnv(oldEnvId);
}
function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
return (oldEnvId: string | null, newEnvId: string, activeSessionId: string | null) => {
const { api, currentLayoutEnvId, preMaximizeLayout } = get();
if (!api) {
debugSwitch("envSwitch: skip (no api)", { oldEnvId, newEnvId, activeSessionId });
return;
}
if (IS_DEBUG) {
debugSwitch("envSwitch: entry", {
oldEnvId,
newEnvId,
activeSessionId,
currentLayoutEnvId,
maximized: !!preMaximizeLayout,
livePanelIds: api.panels.map((p) => p.id),
});
}
// Same-env switch (e.g. between sessions of the same task) is a no-op.
// The layout, terminals, and env-scoped portals already belong to this env.
if (currentLayoutEnvId === newEnvId) {
debugSwitch("envSwitch: skip (same env)", { newEnvId });
return;
}
// First adoption — onReady already built the layout; just adopt it.
if (!oldEnvId && !currentLayoutEnvId) {
set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId });
if (restoreMaximizeFromStorage(api, newEnvId, set)) return;
set({ isRestoringLayout: false, currentLayoutEnvId: newEnvId });
try {
setEnvLayout(newEnvId, api.toJSON());
} catch {
/* ignore */
}
return;
}
// When oldEnvId is null but there is a live layout env (e.g. the
// useEnvSwitchCleanup hook fires after passing through a null state),
// fall back to currentLayoutEnvId so we correctly save and release the
// outgoing env rather than silently skipping it.
const effectiveOld = oldEnvId ?? currentLayoutEnvId;
saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, get().pinnedWidths);
set({ preMaximizeLayout: null, maximizedGroupId: null });
set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId });
try {
if (restoreMaximizeFromStorage(api, newEnvId, set)) return;
const measured = measureDockviewContainer(api);
const ids = performEnvSwitch({
api,
oldEnvId: effectiveOld,
newEnvId,
activeSessionId,
safeWidth: measured.width,
safeHeight: measured.height,
buildDefault: (a) => get().buildDefaultLayout(a),
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset),
});
set(ids);
enforceFromStore(api, get);
set({ isRestoringLayout: false });
if (IS_DEBUG) {
debugWidths(
`env-switch-done old=${effectiveOld ?? "-"} new=${newEnvId} ` +
`${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
panelPortalManager.reconcile(new Set(api.panels.map((p) => p.id)));
} catch {
set({ isRestoringLayout: false });
}
};
}
function buildMaximizeActions(set: StoreSet, get: StoreGet) {
return {
maximizeGroup: (groupId: string) => {
const { api, preMaximizeLayout, currentLayoutEnvId } = get();
if (!api) return;
if (preMaximizeLayout) {
get().exitMaximizedLayout();
return;
}
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const current = fromDockviewApi(api);
let targetGroup: {
panels: LayoutState["columns"][0]["groups"][0]["panels"];
activePanel?: string;
} | null = null;
for (const col of current.columns) {
for (const g of col.groups) {
if (g.id === groupId) {
targetGroup = { panels: g.panels, activePanel: g.activePanel };
break;
}
}
if (targetGroup) break;
}
if (!targetGroup || targetGroup.panels.length === 0) return;
const sidebarCol = current.columns.find((c) => c.id === "sidebar");
const columns: LayoutState["columns"] = [];
if (sidebarCol) columns.push(sidebarCol);
columns.push({
id: "maximized",
groups: [{ panels: targetGroup.panels, activePanel: targetGroup.activePanel }],
});
const maximizedLayout: LayoutState = { columns };
set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId });
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
applyLayoutAndSet(api, maximizedLayout, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
if (currentLayoutEnvId) {
setEnvMaximizeState(currentLayoutEnvId, {
preMaximizeLayout: current as unknown as object,
maximizedDockviewJson: api.toJSON(),
});
}
set({ isRestoringLayout: false });
});
},
exitMaximizedLayout: () => {
const { api, preMaximizeLayout, currentLayoutEnvId } = get();
if (!api || !preMaximizeLayout) return;
preserveChatScrollDuringLayout();
const measured = measureDockviewContainer(api);
const safeWidth = measured.width;
const safeHeight = measured.height;
const liveWidths = get().pinnedWidths;
set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null });
if (currentLayoutEnvId) {
removeEnvMaximizeState(currentLayoutEnvId);
}
applyLayoutAndSet(api, preMaximizeLayout, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
},
};
}
function performBuildDefault(
api: DockviewApi,
set: StoreSet,
get: StoreGet,
intentName?: string,
): void {
const { userDefaultLayout } = get();
const intent = intentName ? resolveNamedIntent(intentName) : null;
const freshPinned = new Map<string, number>();
// Capture dimensions before layout change — api.width can become stale
// after fromJSON inside applyLayout
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
if (IS_DEBUG) {
debugWidths(
`build-default-entry intent=${intentName ?? "-"} ` +
`measured=${safeWidth}x${safeHeight} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
set({ isRestoringLayout: true, pinnedWidths: freshPinned });
const basePreset = intent?.preset as BuiltInPreset | undefined;
let state = basePreset
? getPresetLayout(basePreset)
: (userDefaultLayout ?? getPresetLayout(get().defaultPreset));
if (intent?.panels?.length) {
state = injectIntentPanels(state, intent.panels);
}
if (intent?.activePanels) {
state = applyActivePanelOverrides(state, intent.activePanels);
}
const ids = applyLayout(api, state, freshPinned, safeWidth, safeHeight);
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
const pending = get().deferredPanelActions;
if (pending.length > 0) {
set({ deferredPanelActions: [] });
applyDeferredPanelActions(api, pending);
}
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
if (IS_DEBUG) {
debugWidths(`build-default-done ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
set({ isRestoringLayout: false });
});
}
export const useDockviewStore = create<DockviewStore>((set, get) => ({
api: null,
activeFilePath: null,
setApi: (api) => {
set({ api, activeFilePath: null });
if (typeof window !== "undefined") {
// Exposed for E2E tests to assert on panel/group placement. Harmless in
// prod; the DockviewApi is already reachable via the store in devtools.
type TestWindow = {
__dockviewApi__: DockviewApi | null;
__setPinnedTarget__?: typeof setPinnedTarget;
};
const w = window as unknown as TestWindow;
w.__dockviewApi__ = api;
// E2E test helpers: let `resizeColumnViaSplitview` update the target
// width after a programmatic resize (mirroring the sash-drag mouseup).
w.__setPinnedTarget__ = setPinnedTarget;
}
if (api) {
const resolveFilePath = (panelId: string | undefined): string | null => {
if (!panelId) return null;
if (panelId.startsWith("file:")) return panelId.slice(5);
if (panelId.startsWith("diff:file:")) return panelId.slice("diff:file:".length);
if (panelId === "preview:file-editor" || panelId === "preview:file-diff") {
const path = (api.getPanel(panelId)?.params as Record<string, unknown> | undefined)
?.path as string | undefined;
return path ?? null;
}
return null;
};
api.onDidActivePanelChange((event) => {
set({ activeFilePath: resolveFilePath(event?.id) });
});
// Track per-panel param-change subscriptions so they can be disposed when
// the panel is removed (e.g. across env switches that re-create the
// preview panel) instead of relying on dockview's internal cleanup.
const paramSubs = new Map<string, { dispose: () => void }>();
api.onDidAddPanel((panel) => {
// The preview file-editor panel reuses a single dockview panel and swaps
// its `params.path` via `updateParameters` when the user previews a
// different file. Dockview does not refire `onDidActivePanelChange` for
// params-only updates on an already-active panel, so subscribe to the
// panel's own parameter-change event and refresh `activeFilePath`.
if (panel.id !== "preview:file-editor" && panel.id !== "preview:file-diff") return;
paramSubs.get(panel.id)?.dispose();
const sub = panel.api.onDidParametersChange(() => {
if (!panel.api.isActive) return;
set({ activeFilePath: resolveFilePath(panel.id) });
});
paramSubs.set(panel.id, sub);
});
api.onDidRemovePanel((panel) => {
const sub = paramSubs.get(panel.id);
if (sub) {
sub.dispose();
paramSubs.delete(panel.id);
}
});
}
},
activeGroupId: null,
selectedDiff: null,
setSelectedDiff: (diff) => set({ selectedDiff: diff }),
openFiles: new Map(),
...buildFileStateActions(set),
centerGroupId: CENTER_GROUP,
rightTopGroupId: RIGHT_TOP_GROUP,
rightBottomGroupId: RIGHT_BOTTOM_GROUP,
sidebarGroupId: SIDEBAR_GROUP,
sidebarVisible: true,
rightPanelsVisible: true,
pinnedWidths: new Map(),
setPinnedWidth: (columnId, width) => {
set((prev) => {
const m = new Map(prev.pinnedWidths);
m.set(columnId, width);
return { pinnedWidths: m };
});
},
userDefaultLayout: null,
setUserDefaultLayout: (layout) => set({ userDefaultLayout: layout }),
...buildVisibilityActions(set, get),
...buildPresetActions(set, get),
defaultPreset: "default",
setDefaultPreset: (preset) => set({ defaultPreset: preset }),
isRestoringLayout: false,
currentLayoutEnvId: null,
deferredPanelActions: [],
queuePanelAction: (action) =>
set((prev) => ({
deferredPanelActions: [...prev.deferredPanelActions, action],
})),
switchEnvLayout: buildEnvSwitchAction(set, get),
buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName),
resetLayout: () => {
const { api } = get();
if (api) get().buildDefaultLayout(api);
},
pendingChatScrollTop: null,
setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }),
preMaximizeLayout: null,
maximizedGroupId: null,
...buildMaximizeActions(set, get),
...buildPanelActions(set, get),
...buildExtraPanelActions(get),
}));
/**
* Perform a layout switch between task environments. Same-env (e.g. between
* sessions of the same task) is a no-op — terminals + layout stay put.
*
* `activeSessionId` is the session whose chat panel should be present in the
* resulting layout. It can differ across sessions of the same env, but layout
* reuse means we just ensure the right session: chat panel is visible.
*/
export function performLayoutSwitch(
oldEnvId: string | null,
newEnvId: string,
activeSessionId: string | null,
): void {
useDockviewStore.getState().switchEnvLayout(oldEnvId, newEnvId, activeSessionId);
}
/**
* Release the dockview to a clean default layout — used when selecting a task
* that has no session (and prepare failed to launch one). Without this the
* dockview keeps the outgoing env's panels live but disconnected from any
* active session, and the corrupted state can be persisted on the next save.
*/
export function releaseLayoutToDefault(oldEnvId: string | null): void {
const { api, currentLayoutEnvId, preMaximizeLayout, buildDefaultLayout, pinnedWidths } =
useDockviewStore.getState();
if (!api) return;
const effectiveOld = oldEnvId ?? currentLayoutEnvId;
saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, pinnedWidths);
useDockviewStore.setState({
preMaximizeLayout: null,
maximizedGroupId: null,
currentLayoutEnvId: null,
});
buildDefaultLayout(api);
}