Files
kandev/apps/web/components/task/dockview-layout-setup.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

128 lines
5.0 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 { setEnvLayout } 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>,
envIdRef: 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();
const envId = envIdRef.current;
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
if (envId) {
setEnvLayout(envId, 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 + terminal stop APIs are keyed by sessionId on the wire. The
// portal entry now stores the env it belongs to (or undefined for global
// panels like terminal), so resolve to a session in that env — falling
// back to the active session — for the API call.
const sessionForApi = (() => {
const state = appStore.getState();
const active = state.tasks.activeSessionId;
if (!entry?.envId) return active;
// Find any session in the entry's env (active session preferred).
if (active && state.environmentIdBySessionId[active] === entry.envId) return active;
const match = Object.entries(state.environmentIdBySessionId).find(
([, eid]) => eid === entry.envId,
);
return match?.[0] ?? active;
})();
if (entry?.component === "vscode" && sessionForApi) stopVscode(sessionForApi);
if (entry?.component === "terminal") {
const terminalId = entry.params.terminalId as string | undefined;
if (terminalId && sessionForApi) stopUserShell(sessionForApi, terminalId);
}
panelPortalManager.release(panel.id);
});
}