Files
kandev/apps/web/lib/state/dockview-env-switch.ts
T
José Almeida 46f20e0dfe fix(web): keep dockview group alive on task switch to stop layout collapse (#1308)
* fix(web): keep dockview group alive on task switch to stop layout collapse

On a fast-path env switch, the incoming session chat was added only after
removeEphemeralPanels closed the outgoing chat. When that chat was the last
surviving panel in its group (e.g. the default layout's chat column), removing
it first emptied — and so destroyed — the group. The captured group id then no
longer existed, and post-#1165 the old `referenceGroup: "sidebar"` fallback is
dead, so addPanel ran with an undefined position: dockview dropped the incoming
chat into the active (terminal) group and collapsed the grid root to a vertical
stack. The corrupted layout was then persisted, so it survived reloads.

Add the incoming session panel before removing the outgoing one, anchored at the
outgoing panel's current index, so the group stays alive throughout the swap.
Removing the panels ahead of the insert point shifts the new panel into the same
final slot, so computeSurvivingIndex is no longer needed.

* docs(web): note the raw-index equivalence assumption in env switch
2026-06-08 17:33:54 +01:00

525 lines
20 KiB
TypeScript

/**
* Env switch logic for dockview layout management.
*
* Layouts are keyed by `taskEnvironmentId`. Sessions sharing an env reuse the
* same layout, so switching between same-env sessions is a no-op at the
* layout level (handled by the caller). Cross-env switches use either a
* "fast path" (skip fromJSON when the structure already matches) or a
* "slow path" (full layout rebuild via fromJSON).
*/
import type { DockviewApi, SerializedDockview } from "dockview-react";
import { getEnvLayout } from "@/lib/local-storage";
import { applyLayoutFixups } from "./dockview-layout-builders";
import { isLayoutShapeHealthy } from "./dockview-layout-health";
import {
fromDockviewApi,
savedLayoutMatchesLive,
layoutStructuresMatch,
getPinnedWidth,
getRootSplitview,
setPinnedTarget,
} from "./layout-manager";
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "./dockview-env-scoped-components";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "./dockview-widths-debug";
const debug = createDebugLogger("dockview:env-switch");
const debugWidths = createDebugLogger("dockview:widths");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function snapshotGridShape(node: any, depth = 0): unknown {
if (!node || depth > 6) return null;
if (node.type === "leaf") {
return {
type: "leaf",
groupId: node.data?.id,
activeView: node.data?.activeView,
views: node.data?.views,
};
}
if (node.type === "branch" && Array.isArray(node.data)) {
return {
type: "branch",
orientation: node.orientation,
children: node.data.map((c: unknown) => snapshotGridShape(c, depth + 1)),
};
}
return null;
}
const EPHEMERAL_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS;
/** Fetch the saved layout for an env, dropping it if its shape is corrupted. */
function getHealthyEnvLayout(envId: string): object | null {
const saved = getEnvLayout(envId);
if (!saved) return null;
return isLayoutShapeHealthy(saved) ? saved : null;
}
/** Check whether a serialized dockview layout contains ephemeral panels. */
function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const panels = (serialized as any).panels as
| Record<string, { contentComponent?: string }>
| undefined;
if (!panels) return false;
return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? ""));
}
/** Walk the serialized grid tree collecting (groupId, activeView) for each leaf. */
function collectSavedActiveViews(
saved: SerializedDockview,
): Array<{ groupId: string; activeView: string }> {
const out: Array<{ groupId: string; activeView: string }> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const walk = (node: any): void => {
if (!node) return;
if (Array.isArray(node.data)) {
for (const child of node.data) walk(child);
return;
}
const data = node.data;
if (data?.id && data.activeView) out.push({ groupId: data.id, activeView: data.activeView });
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
walk((saved as any).grid?.root);
return out;
}
/**
* Restore each group's `activeView` from the saved layout. The fast path
* doesn't call `fromJSON`, so per-group active tabs would otherwise carry
* over from the outgoing env (e.g. Task B left "changes" focused in the
* right group, and switching back to Task A would still show "changes"
* even though Task A had "plan" active when it was last saved).
*
* The saved `activeGroup` is applied last so the resulting global focus
* matches what was persisted.
*/
function restoreSavedActiveViews(api: DockviewApi, saved: SerializedDockview): void {
const entries = collectSavedActiveViews(saved);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const savedActiveGroup = (saved as any).activeGroup as string | undefined;
const ordered = savedActiveGroup
? [
...entries.filter((e) => e.groupId !== savedActiveGroup),
...entries.filter((e) => e.groupId === savedActiveGroup),
]
: entries;
for (const { groupId, activeView } of ordered) {
const group = api.groups.find((g) => g.id === groupId);
if (!group) continue;
const panel = group.panels.find((p) => p.id === activeView);
if (panel) {
try {
panel.api.setActive();
} catch {
/* panel may be in a transient state */
}
}
}
}
export type EnvSwitchParams = {
api: DockviewApi;
oldEnvId: string | null;
newEnvId: string;
/** Active session for the incoming env — used to keep the right session chat tab. */
activeSessionId: string | null;
safeWidth: number;
safeHeight: number;
buildDefault: (api: DockviewApi) => void;
getDefaultLayout: () => LayoutState;
};
/**
* Predicate matching panels that `removeEphemeralPanels` will close.
*
* Ephemeral panels (file-editors, diffs, commit-details, etc.) are env-scoped
* and never carry across switches. When `keepSessionId` is provided, chat
* panels for any other session are also removed so the old env's session tab
* doesn't bleed into the new env.
*/
function shouldRemoveDuringSwitch(
panel: { id: string; api: { component: string } },
keepSessionId: string | null,
): boolean {
const comp = panel.api.component;
if (EPHEMERAL_COMPONENTS.has(comp)) return true;
if (
keepSessionId !== null &&
comp === "chat" &&
panel.id.startsWith("session:") &&
panel.id !== `session:${keepSessionId}`
) {
return true;
}
return false;
}
/**
* Close every panel that matches `shouldRemoveDuringSwitch`. Used to clear
* env-scoped ephemerals before the new env's panels are restored.
*/
function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): void {
const toRemove = api.panels.filter((p) => shouldRemoveDuringSwitch(p, keepSessionId));
for (const p of toRemove) {
try {
p.api.close();
} catch {
/* panel may already be gone */
}
}
}
/**
* Replace stale session chat panels with the incoming active session.
*
* A "stale" session is any `session:*` panel whose id isn't
* `session:${keepSessionId}` — typically a phantom carried in from a saved
* layout whose session belongs to a different env (or has been deleted).
*
* Before closing the first stale panel, add the active session at the same
* (group, tab-index). This preserves the user's grouping when the stale was
* co-tabbed with non-session siblings (pr-detail, dragged file-editors,
* etc.) — without it, the siblings would be orphaned in a group with no
* session, and `useAutoSessionTab` would later add the active session as a
* fresh split next to the sidebar.
*
* File-editors/diffs/browser/etc. are NEVER touched here — they
* legitimately belong to this env's saved state.
*/
function replaceStaleSessionPanels(api: DockviewApi, keepSessionId: string | null): void {
const keepId = keepSessionId ? `session:${keepSessionId}` : null;
// keepId=null (sessionless task) → strips all session panels, unlike the
// fast path's shouldRemoveDuringSwitch which keeps them. In practice
// sessionless tasks should have no session panels; useAutoSessionTab
// re-adds the panel when a session arrives.
const stale = api.panels.filter(
(p) => p.api.component === "chat" && p.id.startsWith("session:") && p.id !== keepId,
);
// Anchor the active session to the first stale's (group, index) so co-tabbed
// siblings (pr-detail etc.) stay grouped with the agent tab. Skipped when:
// - no keepSessionId (sessionless task)
// - the active session panel already exists in the layout
// - the stale's group is missing from the live api (defensive)
//
// Limitation: if the saved layout had stale sessions in multiple groups
// (rare — requires multi-session contamination across env boundaries),
// only the first stale's group keeps its siblings. Sessions in other
// groups still close, orphaning anything co-tabbed with them. One active
// session can only re-anchor one group.
if (keepSessionId && !api.getPanel(`session:${keepSessionId}`) && stale.length > 0) {
const first = stale[0];
const groupId = first.group.id;
if (api.groups.some((g) => g.id === groupId)) {
const idx = first.group.panels.findIndex((p) => p.id === first.id);
addIncomingSessionPanel(api, keepSessionId, groupId, idx);
}
}
if (isDebug()) {
debug("replaceStaleSessionPanels", {
keepSessionId,
livePanelIds: api.panels.map((p) => p.id),
removingIds: stale.map((p) => p.id),
});
}
for (const p of stale) {
try {
p.api.close();
} catch {
/* panel may already be gone */
}
}
}
/**
* Fast path: check if we can skip `api.fromJSON()` because the layout
* structure hasn't changed. Returns group IDs if the fast path was taken,
* or null if a full rebuild is needed.
*/
function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
const { api, newEnvId, activeSessionId, getDefaultLayout } = params;
const currentLayout = fromDockviewApi(api);
const saved = getHealthyEnvLayout(newEnvId);
let structuresMatch = false;
if (saved) {
structuresMatch = savedLayoutMatchesLive(currentLayout, saved as SerializedDockview);
} else {
structuresMatch = layoutStructuresMatch(currentLayout, getDefaultLayout());
}
if (!structuresMatch) {
if (isDebug()) {
debug("tryFastEnvSwitch: structures do not match, falling back to slow path", {
newEnvId,
hasSaved: !!saved,
currentPanelIds: api.panels.map((p) => p.id),
});
}
return null;
}
if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) {
debug("tryFastEnvSwitch: saved layout has ephemeral panels, falling back to slow path", {
newEnvId,
});
return null;
}
if (isDebug()) {
debug("tryFastEnvSwitch: taking fast path", {
newEnvId,
activeSessionId,
hasSaved: !!saved,
currentPanelIds: api.panels.map((p) => p.id),
});
}
// Prefer the active session panel so multi-session tasks anchor the
// incoming panel to the group the user was looking at, not whichever
// session tab happens to come first in `api.panels` iteration order.
const isSessionPanel = (p: (typeof api.panels)[number]) =>
p.id.startsWith("session:") || p.api.component === "chat";
const outgoingSessionPanel =
api.panels.find((p) => isSessionPanel(p) && p.api.isActive) ?? api.panels.find(isSessionPanel);
const outgoingGroup = outgoingSessionPanel?.group;
const outgoingGroupId = outgoingGroup?.id;
// The outgoing session panel's current index in its group. We insert the
// incoming session at this same slot *before* removing the outgoing one, so
// removing the panels ahead of it shifts the new panel into the right final
// position (equivalent to the old "survivor index" math, minus the group
// death described below). This equivalence relies on every panel that
// `removeEphemeralPanels` removes *ahead of* the insert position being
// ephemeral/stale (they'd have been excluded by the old survivor count too).
// If `shouldRemoveDuringSwitch` ever retains a non-stale panel before the
// outgoing slot, this raw index would land the new panel off by one.
const outgoingIndex =
outgoingGroup && outgoingSessionPanel
? outgoingGroup.panels.findIndex((p) => p.id === outgoingSessionPanel.id)
: -1;
// Add the incoming session panel BEFORE removing the outgoing chat. Removing
// first can leave the outgoing group empty, at which point dockview destroys
// it: `outgoingGroupId` no longer exists, and post-#1165 the old
// `referenceGroup: "sidebar"` fallback is dead, so `addPanel` runs with an
// undefined position and dockview drops the incoming chat into whatever group
// is active (e.g. the terminal) — collapsing the grid root to a vertical
// stack. Adding first keeps the group alive throughout the swap.
if (activeSessionId && !api.getPanel(`session:${activeSessionId}`)) {
addIncomingSessionPanel(api, activeSessionId, outgoingGroupId, outgoingIndex);
}
removeEphemeralPanels(api, activeSessionId);
// The fast path skips `fromJSON`, so per-group active tabs from the
// outgoing env would otherwise persist into the incoming env. Reapply
// them from the saved layout to match what `fromJSON` would have done.
if (saved) restoreSavedActiveViews(api, saved as SerializedDockview);
// Column widths from the outgoing env stay live across the switch because
// we skipped fromJSON. Apply the target env's widths explicitly:
// - saved layout exists → use its serialized sizes
// - no saved layout (brand-new env) → compute fresh defaults via
// getPinnedWidth (ratio-based, clamped to legacy initial cap)
applyPinnedColumnSizes(api, saved as SerializedDockview | null, params.safeWidth);
api.layout(params.safeWidth, params.safeHeight);
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview | null));
}
/**
* The per-env saved width of the right column (the last grid-root child) for a
* default-preset layout, or undefined when the saved layout has no distinct
* right column. Forwarded to `applyLayoutFixups` so the fixups pass anchors the
* pinned right target to this stable saved width instead of dockview's
* transient post-`fromJSON` live size (the dockview-wrong-width drift).
*/
export function savedRightColumnWidth(saved: SerializedDockview | null): number | undefined {
if (!saved) return undefined;
const sizes = extractSavedColumnSizes(saved);
if (!sizes || sizes.length < 3) return undefined;
const w = sizes[sizes.length - 1];
return Number.isFinite(w) && w > 0 ? w : undefined;
}
/** Extract per-column sizes from a saved SerializedDockview grid root. */
function extractSavedColumnSizes(saved: SerializedDockview): number[] | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const root = (saved as any).grid?.root;
if (!root?.data || !Array.isArray(root.data)) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return root.data.map((child: any) => (typeof child?.size === "number" ? child.size : NaN));
}
/** Compute the target width for a pinned column from saved sizes or fall
* back to the preset's ratio-based default. */
function targetPinnedWidth(
col: LayoutState["columns"][number],
index: number,
savedSizes: number[] | null,
totalWidth: number,
): number | undefined {
if (savedSizes && Number.isFinite(savedSizes[index])) return savedSizes[index];
return getPinnedWidth(col, totalWidth, undefined);
}
/**
* After a fast-path env switch, override the inherited column widths with
* the target env's values. Without this, the outgoing env's user-resized
* widths bleed into the new env — a brand-new task would open at whatever
* width the user last dragged the previous task's sidebar/right to.
*/
function applyPinnedColumnSizes(
api: DockviewApi,
saved: SerializedDockview | null,
totalWidth: number,
): void {
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
const savedSizes = saved ? extractSavedColumnSizes(saved) : null;
const liveLayout = fromDockviewApi(api);
if (isDebug()) {
const savedStr = savedSizes
? savedSizes.map((n) => (Number.isFinite(n) ? String(Math.round(n)) : "-")).join(",")
: "-";
debugWidths(
`env-switch-resize totalWidth=${totalWidth} savedSizes=${savedStr} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
for (let i = 0; i < liveLayout.columns.length && i < sv.length; i++) {
const col = liveLayout.columns[i];
if (col.id !== "sidebar" && col.id !== "right") continue;
// Sidebar uses the GLOBAL width pref (single source of truth across tasks),
// so it ignores this env's saved size. Right keeps per-env saved sizes.
const target =
col.id === "sidebar"
? getPinnedWidth(col, totalWidth, undefined)
: targetPinnedWidth(col, i, savedSizes, totalWidth);
if (typeof target !== "number" || target <= 0) continue;
try {
sv.resizeView(i, target);
// Update the pinned-target so enforcement keeps the new env's width
// through subsequent rebalances.
setPinnedTarget(col.id, target);
if (isDebug()) {
debugWidths(`env-switch-resize-col col=${col.id} idx=${i} target=${Math.round(target)}`);
}
} catch {
/* dockview rejects out-of-range sizes — ignore */
}
}
}
/**
* Add the incoming task's session chat panel, restoring it to the same tab
* slot the outgoing session occupied within `outgoingGroupId` when possible.
*/
function addIncomingSessionPanel(
api: DockviewApi,
sessionId: string,
outgoingGroupId: string | undefined,
outgoingIndex: number,
): void {
let position: import("dockview-react").AddPanelOptions["position"];
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
position =
outgoingIndex >= 0
? { referenceGroup: outgoingGroupId, index: outgoingIndex }
: { referenceGroup: outgoingGroupId };
} else if (api.getPanel("sidebar")) {
position = { direction: "right" as const, referencePanel: "sidebar" };
}
api.addPanel({
id: `session:${sessionId}`,
component: "chat",
tabComponent: "sessionTab",
title: "Agent",
params: { sessionId },
position,
});
}
/**
* Switch the dockview layout between task environments.
*
* Uses a fast path when layouts are structurally identical (common case),
* falling back to a full `api.fromJSON()` rebuild when they differ.
*
* The caller is responsible for saving the old env's layout and releasing
* env-scoped portals before calling this function.
*/
export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
const { api, oldEnvId, newEnvId, activeSessionId, safeWidth, safeHeight, buildDefault } = params;
if (isDebug()) {
debug("performEnvSwitch: entry", {
oldEnvId,
newEnvId,
activeSessionId,
livePanelIdsBefore: api.panels.map((p) => p.id),
});
}
const fastResult = tryFastEnvSwitch(params);
if (fastResult) {
if (isDebug()) {
debug("performEnvSwitch: completed via fast path", {
newEnvId,
livePanelIdsAfter: api.panels.map((p) => p.id),
});
}
return fastResult;
}
const saved = getHealthyEnvLayout(newEnvId);
if (saved) {
try {
if (isDebug()) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const savedPanelIds = Object.keys((saved as any).panels ?? {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const savedShape = snapshotGridShape((saved as any).grid?.root);
debug("performEnvSwitch: slow path - calling api.fromJSON", {
newEnvId,
savedPanelIds,
savedShape,
});
}
api.fromJSON(saved as SerializedDockview);
// Saved layout may carry a stale session panel from a previously-deleted
// task (phantom). Replace stale session panels with the incoming active
// session in the same (group, tab-index), then close the stale ones —
// preserves grouping with co-tabbed siblings (pr-detail, dragged file
// editors, etc.). File editors/diffs/etc. on their own are legitimately
// part of this env's saved state and must NOT be touched.
// useAutoSessionTab will still no-op if the panel was just added here.
replaceStaleSessionPanels(api, activeSessionId);
api.layout(safeWidth, safeHeight);
if (isDebug()) {
debug("performEnvSwitch: completed via slow path (fromJSON)", {
newEnvId,
livePanelIdsAfter: api.panels.map((p) => p.id),
});
}
return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview));
} catch (err) {
console.warn("performEnvSwitch: fromJSON threw", err);
debug("performEnvSwitch: fromJSON threw, falling through to default", { newEnvId, err });
/* fall through to default layout build */
}
}
debug("performEnvSwitch: building default layout", { newEnvId, hasSaved: !!saved });
buildDefault(api);
api.layout(safeWidth, safeHeight);
if (isDebug()) {
debug("performEnvSwitch: completed via default build", {
newEnvId,
livePanelIdsAfter: api.panels.map((p) => p.id),
});
}
return applyLayoutFixups(api);
}