45ee2ff48f
* 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
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|