Files
kandev/apps/web/lib/state/dockview-session-switch.ts
T
José Almeida 7f9cff0dcb fix(web): fix dock view task-switching regressions (#595)
* fix(web): fix dock view task-switching regressions

Three bugs caused broken UI when switching tasks in the dock view:

1. Environment key migration was dead code — data stored under fallback
   sessionId keys became invisible when the environmentIdBySessionId
   mapping was later set, because migrateEnvKeyedData was never called
   in production (only in tests via registerSessionEnvironment).

2. Orphaned portals leaked on every fast-path session switch — the
   isRestoringLayout guard in setupPortalCleanup blocked portal release
   for panels removed during the fast path, accumulating invisible React
   trees with live WS subscriptions.

3. Chat panel gap during fast-path transition — old session tab was
   removed synchronously but new tab was deferred to a useEffect,
   leaving a brief layout gap.

* fix(web): remove superfluous args in test helper

Address CodeQL finding — createSessionSlice only takes `set`, not
`get` and `api`.

* fix(web): add addPanel test assertions and doc comment

Address claude-review blocker: assert that fast-path creates the
session panel with correct id/params, and skips creation when it
already exists. Add doc comment noting syncEnvironmentMapping
requires the combined store draft.

* test(web): add PanelPortalManager.reconcile tests

Export PanelPortalManager class for testability and add tests covering
reconcile removal, no-op, and listener notification paths.
2026-04-15 21:40:16 +01:00

146 lines
5.2 KiB
TypeScript

/**
* Session switch logic for dockview layout management.
*
* Handles both a "fast path" (skip fromJSON when layout structure matches)
* and a "slow path" (full layout rebuild via fromJSON).
*/
import type { DockviewApi, SerializedDockview } from "dockview-react";
import { getSessionLayout } from "@/lib/local-storage";
import { applyLayoutFixups } from "./dockview-layout-builders";
import { fromDockviewApi, savedLayoutMatchesLive, layoutStructuresMatch } from "./layout-manager";
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
export type SessionSwitchParams = {
api: DockviewApi;
oldSessionId: string | null;
newSessionId: string;
safeWidth: number;
safeHeight: number;
buildDefault: (api: DockviewApi) => void;
getDefaultLayout: () => LayoutState;
};
/**
* Remove ephemeral panels (file-editors, diffs, commit-details) from the
* live layout. These are session-specific panels that shouldn't carry over.
*
* When `keepSessionId` is provided, session chat panels whose ID does not
* match `session:{keepSessionId}` are also removed. This handles cross-task
* switches where the fast path is taken: without this, session tabs from the
* old task remain visible alongside the new task's tab.
*/
function removeEphemeralPanels(api: DockviewApi, keepSessionId?: string): void {
const toRemove = api.panels.filter((p) => {
const comp = p.api.component;
if (comp === "file-editor" || comp === "diff-viewer" || comp === "commit-detail") {
return true;
}
// Remove session chat tabs that belong to a different session.
// This covers cross-task switches where the layout structures match and
// the fast path is taken — old task's session tabs must not bleed through.
if (
keepSessionId !== undefined &&
comp === "chat" &&
p.id.startsWith("session:") &&
p.id !== `session:${keepSessionId}`
) {
return true;
}
return false;
});
for (const p of toRemove) {
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 tryFastSessionSwitch(params: SessionSwitchParams): LayoutGroupIds | null {
const { api, newSessionId, getDefaultLayout } = params;
const currentLayout = fromDockviewApi(api);
const saved = getSessionLayout(newSessionId);
let structuresMatch = false;
if (saved) {
// Compare live layout against saved layout by structural component set
structuresMatch = savedLayoutMatchesLive(currentLayout, saved as SerializedDockview);
} else {
// No saved layout — target is the default; compare LayoutState structures
structuresMatch = layoutStructuresMatch(currentLayout, getDefaultLayout());
}
if (!structuresMatch) return null;
// Fast path: keep the grid structure, clean up ephemeral panels and any
// session chat tabs that belong to a different session (cross-task switch).
// Session-scoped portals (browser, vscode, etc.) will be re-acquired
// via usePortalSlot's sessionId dependency change.
removeEphemeralPanels(api, newSessionId);
// Create the new session tab inline so there's no gap between removing the
// old tab and creating the new one. useAutoSessionTab will detect the panel
// already exists and skip creation.
if (!api.getPanel(`session:${newSessionId}`)) {
const chatPanel = api.panels.find(
(p) => p.id.startsWith("session:") || p.api.component === "chat",
);
const sidebarPanel = api.getPanel("sidebar");
let position: import("dockview-react").AddPanelOptions["position"];
if (chatPanel) {
position = { referenceGroup: chatPanel.group.id };
} else if (sidebarPanel) {
position = { direction: "right" as const, referencePanel: "sidebar" };
}
api.addPanel({
id: `session:${newSessionId}`,
component: "chat",
tabComponent: "sessionTab",
title: "Agent",
params: { sessionId: newSessionId },
position,
});
}
api.layout(params.safeWidth, params.safeHeight);
return applyLayoutFixups(api);
}
/**
* Switch the dockview layout between sessions.
*
* 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 session layout and releasing
* session-scoped portals before calling this function.
*/
export function performSessionSwitch(params: SessionSwitchParams): LayoutGroupIds {
const { api, newSessionId, safeWidth, safeHeight, buildDefault } = params;
// Try fast path: skip fromJSON when layout structure hasn't changed
const fastResult = tryFastSessionSwitch(params);
if (fastResult) return fastResult;
// Slow path: full layout rebuild via fromJSON
const saved = getSessionLayout(newSessionId);
if (saved) {
try {
api.fromJSON(saved as SerializedDockview);
api.layout(safeWidth, safeHeight);
return applyLayoutFixups(api);
} catch {
/* fall through */
}
}
buildDefault(api);
api.layout(safeWidth, safeHeight);
return applyLayoutFixups(api);
}