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

181 lines
7.1 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 { isLayoutShapeHealthy } from "./dockview-layout-health";
import { fromDockviewApi, savedLayoutMatchesLive, layoutStructuresMatch } from "./layout-manager";
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
const EPHEMERAL_COMPONENTS = new Set(["file-editor", "diff-viewer", "commit-detail"]);
/** Fetch the saved layout for a session, dropping it if its shape is corrupted. */
function getHealthySessionLayout(sessionId: string): object | null {
const saved = getSessionLayout(sessionId);
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 ?? ""));
}
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 = getHealthySessionLayout(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;
// If the saved layout contains ephemeral panels (file-editors, diffs,
// commit-details), fall through to the slow path so that `api.fromJSON()`
// restores them. The fast path skips fromJSON and removeEphemeralPanels
// would discard the current ones without restoring the target session's.
if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null;
// Capture the outgoing session tab's group BEFORE removing it so we can
// place the new session tab in the same group. Without this, the lookup
// after removeEphemeralPanels finds no chat/session panels and falls
// through to a sidebar split — putting the new tab in a new group while
// the PR panel stays in the old center group.
const outgoingSessionPanel = api.panels.find(
(p) => p.id.startsWith("session:") || p.api.component === "chat",
);
const outgoingGroupId = outgoingSessionPanel?.group?.id;
// 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 sidebarPanel = api.getPanel("sidebar");
let position: import("dockview-react").AddPanelOptions["position"];
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
position = { referenceGroup: outgoingGroupId };
} 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. Use the validating loader
// so a corrupted blob from a previous failed switch is dropped instead of
// applied verbatim (which would collapse the central group).
const saved = getHealthySessionLayout(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);
}