Files
kandev/apps/web/components/task/dockview-layout-setup.ts
T
José Almeida c08d68c0fe refactor: re-key dockview panel state by environmentId instead of sessionId (#491)
* refactor: re-key dockview panel state by environmentId instead of sessionId

- Remove terminal from SESSION_SCOPED_COMPONENTS (uses useEnvironmentSessionId internally)
- Convert ChangesContent and file-editor-panel to useEnvironmentSessionId()
- Re-key sessionCommits store from bySessionId to byEnvironmentId
- Re-key useCumulativeDiff cache by environmentId
- Update WS handler to resolve envKey before invalidating cumulative diff cache
- Update comments across portal manager, session switch, and layout files

* fix: revert useEnvironmentSessionId in file-editor-panel

file-editor is session-scoped and ephemeral — portal is destroyed on
session switch, so useEnvironmentSessionId() has no practical effect.

* fix: address CodeRabbit review comments

- Pass appStore to setupPortalCleanup so terminal tab close still calls
  stopUserShell via activeSessionId fallback (entry.sessionId is now
  undefined for global panels)
- Add request version guard to useCumulativeDiff to discard stale
  responses after environment switches

* fix: scope activeSessionId fallback to terminal only in portal cleanup

vscode is session-scoped and always has entry.sessionId — the
activeSessionId fallback should only apply to terminal (now global).

* fix: migrate stranded env-keyed data on registerSessionEnvironment

When registerSessionEnvironment(sessionId, environmentId) is called,
any data previously stored under the fallback sessionId key is now
migrated to the proper environmentId key. This prevents data from
"vanishing" when selectors start reading by environmentId after the
mapping is registered.

Applies to: sessionCommits, gitStatus, shell outputs/statuses,
userShells.

* fix: extract migrateEnvKeyedData helper to fix lint max-lines

* fix: address CodeRabbit review comments

- Always delete orphaned sessionId key in migrateEnvKeyedData even when
  environmentId key already exists (prevents stale accumulation)
- Reset loading state when switching to an envKey with warm cache to
  avoid brief spinner flash

* fix: always reset loading on envKey switch in useCumulativeDiff

Prevents stuck loading state on rapid A→B→A env switches where the
stale in-flight response is discarded by version guard but loading
is never reset.

* fix: move sync effect before fetch effect in useCumulativeDiff

React 18 batches effects in declaration order. With the sync effect
after the fetch effect, setLoading(false) from the sync overrode
setLoading(true) from the fetch on cold-cache env switches. Moving
the sync effect first ensures the fetch effect's setLoading(true)
wins the batch.

* test: add migrateEnvKeyedData coverage for registerSessionEnvironment

Tests 4 cases: normal migration, don't-clobber existing data,
no-op when sessionId equals environmentId, and partial sub-key
migration.

* fix: close stale-diff gaps in useCumulativeDiff sync effect

- Bump requestVersionRef on envKey change so in-flight fetches for
  the previous envKey are discarded
- Clear loadingState[envKey] so the fetch effect isn't blocked on
  re-entry (A→B→A scenario)
2026-04-14 11:44:38 +01:00

116 lines
4.5 KiB
TypeScript

import type { DockviewReadyEvent } from "dockview-react";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { getRootSplitview } from "@/lib/state/dockview-layout-builders";
import { setSessionLayout } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { stopUserShell } from "@/lib/api/domains/user-shell-api";
const LAYOUT_STORAGE_KEY = "dockview-layout-v1";
function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
if (useDockviewStore.getState().isRestoringLayout) return;
if (api.hasMaximizedGroup() || useDockviewStore.getState().preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
const sidebarW = sv.getViewSize(0);
if (sidebarW > 50) {
const current = useDockviewStore.getState().pinnedWidths.get("sidebar");
if (current !== sidebarW) {
useDockviewStore.getState().setPinnedWidth("sidebar", sidebarW);
}
}
if (sv.length >= 3) {
const rightIdx = sv.length - 1;
const rightW = sv.getViewSize(rightIdx);
if (rightW > 50) {
const current = useDockviewStore.getState().pinnedWidths.get("right");
if (current !== rightW) {
useDockviewStore.getState().setPinnedWidth("right", rightW);
}
}
}
} catch {
/* noop */
}
}
export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void {
const d1 = api.onDidActiveGroupChange((group) => {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api));
trackPinnedWidths(api);
return () => {
d1.dispose();
d2.dispose();
};
}
export function setupLayoutPersistence(
api: DockviewReadyEvent["api"],
saveTimerRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>,
sessionIdRef: React.MutableRefObject<string | null>,
): void {
api.onDidLayoutChange(() => {
if (useDockviewStore.getState().isRestoringLayout) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
try {
const json = api.toJSON();
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
const sid = sessionIdRef.current;
if (sid) {
setSessionLayout(sid, json);
}
} catch {
// Ignore serialization errors
}
}, 300);
});
}
export function setupPortalCleanup(
api: DockviewReadyEvent["api"],
appStore: StoreApi<AppState>,
): void {
api.onDidRemovePanel((panel) => {
if (useDockviewStore.getState().isRestoringLayout) return;
const isMax = useDockviewStore.getState().preMaximizeLayout !== null;
const remaining = api.panels.filter((p) => p.id !== panel.id);
const nonSidebar = remaining.filter((p) => p.api.component !== "sidebar");
// If we're in maximize mode and the last non-sidebar panel was just closed,
// exit maximize to restore the pre-maximize layout (avoids empty view).
// Then remove the closed panel from the restored layout so it doesn't reappear.
if (isMax && nonSidebar.length === 0) {
const removedId = panel.id;
requestAnimationFrame(() => {
useDockviewStore.getState().exitMaximizedLayout();
// exitMaximizedLayout schedules a rAF to finalize — wait for that, then
// remove the panel that was closed (it was re-created from preMaximizeLayout).
requestAnimationFrame(() => {
const restoredPanel = api.getPanel(removedId);
if (restoredPanel) {
restoredPanel.api.close();
}
});
});
}
const entry = panelPortalManager.get(panel.id);
// vscode is session-scoped so entry.sessionId is always set by PortalSlot.
if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId);
// terminal is global (no entry.sessionId) — fall back to the active session.
if (entry?.component === "terminal") {
const terminalSessionId = entry.sessionId ?? appStore.getState().tasks.activeSessionId;
const terminalId = entry.params.terminalId as string | undefined;
if (terminalId && terminalSessionId) stopUserShell(terminalSessionId, terminalId);
}
panelPortalManager.release(panel.id);
});
}