Files
kandev/apps/web/lib/layout/panel-portal-manager.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

216 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PanelPortalManager — singleton registry for persistent dockview panel portals.
*
* When dockview calls `api.fromJSON()` during layout switches, all React panel
* components are unmounted and remounted. This destroys expensive state such as
* xterm WebSocket connections, iframes, and editor instances.
*
* The portal manager lifts panel content out of dockview's lifecycle:
* – Each panel gets a persistent `HTMLDivElement` (the "portal element") that
* lives in a hidden host outside the dockview tree.
* – Dockview panel wrappers adopt the portal element into their own DOM on mount
* and release it on unmount — without destroying it.
* – The actual React component tree is rendered into the portal element via
* `createPortal`, so component state, effects, and DOM survive layout switches.
*
* ## Env scoping
*
* Portals are either **global** or **env-scoped**:
*
* - **Global** (`envId` is `undefined`) — the portal persists across env
* switches. Used for panels that read `activeSessionId` reactively from the
* store and automatically show the correct data for whichever session is active.
* Examples: sidebar, chat, terminal, changes, files, plan.
* Note: terminal, changes, and files use `useEnvironmentSessionId()` to stay
* stable across same-environment session switches.
*
* - **Env-scoped** (`envId` is set) — the portal is bound to the task
* environment that created it and is destroyed via `releaseByEnv()` when the
* user switches away to a different env. Used for panels whose content is
* intrinsically tied to a specific env's runtime state (container processes,
* worktree files, etc.) and cannot simply re-read a store selector to switch
* context. Examples: file-editor, browser, vscode, commit-detail, diff-viewer,
* pr-detail.
*
* See `ENV_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the
* authoritative list and per-component rationale.
*/
import type { DockviewPanelApi } from "dockview-react";
export type PortalEntry = {
/** The persistent DOM element that React portals into. */
element: HTMLDivElement;
/** The dockview component name (e.g. "terminal", "chat"). */
component: string;
/** Latest panel params from dockview. */
params: Record<string, unknown>;
/** Latest dockview panel API handle — updated on each remount. */
api: DockviewPanelApi | null;
/** Task env ID this portal is scoped to (undefined = global, persists across envs). */
envId?: string;
};
type Listener = () => void;
export class PanelPortalManager {
private entries = new Map<string, PortalEntry>();
private listeners = new Set<Listener>();
private version = 0;
/** Monotonic counter bumped on every change — used as a render key by consumers. */
getVersion(): number {
return this.version;
}
/**
* Update a panel's params in place. Triggered by `api.onDidParametersChange`
* after `updateParameters` is called on an existing panel (preview tabs).
*/
updateParams(panelId: string, params: Record<string, unknown>): void {
const entry = this.entries.get(panelId);
if (!entry) return;
entry.params = params;
this.version++;
this.notify();
}
/**
* Get or create the portal entry for a panel.
* Called by the dockview slot wrapper on mount.
*/
acquire(
panelId: string,
component: string,
params: Record<string, unknown>,
api: DockviewPanelApi,
envId?: string,
): PortalEntry {
let entry = this.entries.get(panelId);
if (!entry) {
const el = document.createElement("div");
el.style.display = "contents";
el.dataset.portalPanel = panelId;
entry = { element: el, component, params, api, envId };
this.entries.set(panelId, entry);
this.version++;
this.notify();
} else {
// Panel remounted after fromJSON — update api & params
entry.api = api;
entry.params = params;
entry.component = component;
}
return entry;
}
/** Remove a panel's portal (permanent deletion, e.g. user closes tab). */
release(panelId: string): void {
const entry = this.entries.get(panelId);
if (!entry) return;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
this.version++;
this.notify();
}
/** Release all portals scoped to a specific task env. */
releaseByEnv(envId: string): void {
const toRemove: string[] = [];
for (const [panelId, entry] of this.entries) {
if (entry.envId === envId) {
toRemove.push(panelId);
}
}
if (toRemove.length === 0) return;
for (const panelId of toRemove) {
const entry = this.entries.get(panelId)!;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
}
this.version++;
this.notify();
}
/**
* Release portals whose panel no longer exists in dockview.
* Call after fast-path env switches where `isRestoringLayout` blocked
* the normal `onDidRemovePanel` cleanup.
*/
reconcile(livePanelIds: Set<string>): void {
const toRemove: string[] = [];
for (const panelId of this.entries.keys()) {
if (!livePanelIds.has(panelId)) {
toRemove.push(panelId);
}
}
if (toRemove.length === 0) return;
for (const panelId of toRemove) {
const entry = this.entries.get(panelId)!;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
}
this.version++;
this.notify();
}
/** Release all portals (e.g. when the layout component unmounts entirely). */
releaseAll(): void {
if (this.entries.size === 0) return;
for (const entry of this.entries.values()) {
entry.element.remove();
entry.api = null;
}
this.entries.clear();
this.version++;
this.notify();
}
/** Check if a portal exists for a panel. */
has(panelId: string): boolean {
return this.entries.has(panelId);
}
/** Get a specific entry. */
get(panelId: string): PortalEntry | undefined {
return this.entries.get(panelId);
}
/** All registered panel IDs. */
ids(): string[] {
return Array.from(this.entries.keys());
}
/** Iterate all registered portals (used by the host to render). */
getAll(): Map<string, PortalEntry> {
return this.entries;
}
/** Subscribe to entry additions/removals. Returns unsubscribe fn. */
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const fn of this.listeners) fn();
}
}
/** App-wide singleton. */
export const panelPortalManager = new PanelPortalManager();
/**
* Set the dockview tab title for a panel managed by the portal system.
* Safe to call even when the panel's api isn't available yet.
*/
export function setPanelTitle(panelId: string, title: string): void {
const entry = panelPortalManager.get(panelId);
if (entry?.api && entry.api.title !== title) {
entry.api.setTitle(title);
}
}