Files
kandev/apps/web/components/task/dockview-layout-restore.ts
T
José Almeida 45ee2ff48f feat: key terminals + dockview layout to TaskEnvironment (#755)
* feat(backend): key user shells by TaskEnvironmentID

Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.

- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
  / executor.LaunchAgentRequest, propagated through ToAgentExecution and the
  orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
  it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
  are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
  ResolveScopeKey before calling the runner.

* feat(web): key dockview layout + portals to TaskEnvironmentID

Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.

- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
  -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
  -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
  -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
  Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
  structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
  ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
  sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
  layout/maximize cleanup.

* test: add coverage for env-keyed terminals + layout

Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.

* fix: address env layout PR feedback

* test: fix flaky handshake auth check

* fix: address env scope review findings
2026-05-01 11:00:33 +01:00

130 lines
4.2 KiB
TypeScript

import type { DockviewReadyEvent, SerializedDockview } from "dockview-react";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders";
import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health";
import { getEnvLayout, getEnvMaximizeState } from "@/lib/local-storage";
const LAYOUT_STORAGE_KEY = "dockview-layout-v1";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function sanitizeLayout(layout: any, validComponents: Set<string>): any {
if (!isLayoutShapeHealthy(layout)) return null;
const invalidIds = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validPanels: Record<string, any> = {};
for (const [id, panel] of Object.entries(layout.panels)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const comp = (panel as any).contentComponent;
if (comp && (validComponents.has(comp) || id.startsWith("session:"))) {
validPanels[id] = panel;
} else {
invalidIds.add(id);
}
}
if (invalidIds.size === 0) return layout;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cleanNode(node: any): any {
if (node.type === "leaf") {
const views = (node.data.views as string[]).filter((v) => !invalidIds.has(v));
if (views.length === 0) return null;
const activeView = views.includes(node.data.activeView) ? node.data.activeView : views[0];
return { ...node, data: { ...node.data, views, activeView } };
}
if (node.type === "branch") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const children = (node.data as any[]).map(cleanNode).filter(Boolean);
if (children.length === 0) return null;
return { ...node, data: children };
}
return node;
}
const cleanedRoot = cleanNode(layout.grid.root);
if (!cleanedRoot) return null;
return {
...layout,
grid: { ...layout.grid, root: cleanedRoot },
panels: validPanels,
};
}
function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void {
const savedMax = envId ? getEnvMaximizeState(envId) : null;
if (savedMax) {
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
api.layout(api.width, api.height);
const ids = applyLayoutFixups(api);
type LM = import("@/lib/state/layout-manager").LayoutState;
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM,
});
} else {
const ids = applyLayoutFixups(api);
useDockviewStore.setState(ids);
}
}
function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], envId: string): boolean {
const savedMax = getEnvMaximizeState(envId);
if (!savedMax) return false;
try {
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
api.layout(api.width, api.height);
const ids = applyLayoutFixups(api);
type LM = import("@/lib/state/layout-manager").LayoutState;
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM,
});
return true;
} catch {
return false;
}
}
export function tryRestoreLayout(
api: DockviewReadyEvent["api"],
currentEnvId: string | null,
validComponents: Set<string>,
): boolean {
if (currentEnvId) {
try {
const envLayout = getEnvLayout(currentEnvId);
if (envLayout) {
const sanitized = sanitizeLayout(envLayout, validComponents);
if (!sanitized) {
return false;
}
api.fromJSON(sanitized as SerializedDockview);
applyFixupsWithMaximize(api, currentEnvId);
return true;
}
} catch {
// Per-env restore failed, try global
}
if (tryRestoreMaximizeOnly(api, currentEnvId)) return true;
}
if (!currentEnvId) {
try {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (saved) {
const layout = sanitizeLayout(JSON.parse(saved), validComponents);
if (!layout) return false;
api.fromJSON(layout);
useDockviewStore.setState(applyLayoutFixups(api));
return true;
}
} catch {
// Global restore failed, build default
}
}
return false;
}