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
168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
/**
|
|
* Env switch logic for dockview layout management.
|
|
*
|
|
* Layouts are keyed by `taskEnvironmentId`. Sessions sharing an env reuse the
|
|
* same layout, so switching between same-env sessions is a no-op at the
|
|
* layout level (handled by the caller). Cross-env switches use either a
|
|
* "fast path" (skip fromJSON when the structure already matches) or a
|
|
* "slow path" (full layout rebuild via fromJSON).
|
|
*/
|
|
import type { DockviewApi, SerializedDockview } from "dockview-react";
|
|
import { getEnvLayout } from "@/lib/local-storage";
|
|
import { applyLayoutFixups } from "./dockview-layout-builders";
|
|
import { isLayoutShapeHealthy } from "./dockview-layout-health";
|
|
import { fromDockviewApi, savedLayoutMatchesLive, layoutStructuresMatch } from "./layout-manager";
|
|
import type { LayoutState, LayoutGroupIds } from "./layout-manager";
|
|
|
|
const EPHEMERAL_COMPONENTS = new Set([
|
|
"file-editor",
|
|
"browser",
|
|
"vscode",
|
|
"commit-detail",
|
|
"diff-viewer",
|
|
"pr-detail",
|
|
]);
|
|
|
|
/** Fetch the saved layout for an env, dropping it if its shape is corrupted. */
|
|
function getHealthyEnvLayout(envId: string): object | null {
|
|
const saved = getEnvLayout(envId);
|
|
if (!saved) return null;
|
|
return isLayoutShapeHealthy(saved) ? saved : null;
|
|
}
|
|
|
|
/** Check whether a serialized dockview layout contains ephemeral panels. */
|
|
function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const panels = (serialized as any).panels as
|
|
| Record<string, { contentComponent?: string }>
|
|
| undefined;
|
|
if (!panels) return false;
|
|
return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? ""));
|
|
}
|
|
|
|
export type EnvSwitchParams = {
|
|
api: DockviewApi;
|
|
oldEnvId: string | null;
|
|
newEnvId: string;
|
|
/** Active session for the incoming env — used to keep the right session chat tab. */
|
|
activeSessionId: string | null;
|
|
safeWidth: number;
|
|
safeHeight: number;
|
|
buildDefault: (api: DockviewApi) => void;
|
|
getDefaultLayout: () => LayoutState;
|
|
};
|
|
|
|
/**
|
|
* Remove ephemeral panels (file-editors, diffs, commit-details) from the
|
|
* live layout. These are env-scoped panels that shouldn't carry over.
|
|
*
|
|
* When `keepSessionId` is provided, session chat panels whose ID does not
|
|
* match `session:{keepSessionId}` are also removed. This handles cross-env
|
|
* (cross-task) switches where the fast path is taken: without this, session
|
|
* tabs from the old env's task remain visible alongside the new task's tab.
|
|
*/
|
|
function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): void {
|
|
const toRemove = api.panels.filter((p) => {
|
|
const comp = p.api.component;
|
|
if (EPHEMERAL_COMPONENTS.has(comp)) {
|
|
return true;
|
|
}
|
|
if (
|
|
keepSessionId !== null &&
|
|
comp === "chat" &&
|
|
p.id.startsWith("session:") &&
|
|
p.id !== `session:${keepSessionId}`
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
for (const p of toRemove) {
|
|
try {
|
|
p.api.close();
|
|
} catch {
|
|
/* panel may already be gone */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fast path: check if we can skip `api.fromJSON()` because the layout
|
|
* structure hasn't changed. Returns group IDs if the fast path was taken,
|
|
* or null if a full rebuild is needed.
|
|
*/
|
|
function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
|
|
const { api, newEnvId, activeSessionId, getDefaultLayout } = params;
|
|
const currentLayout = fromDockviewApi(api);
|
|
const saved = getHealthyEnvLayout(newEnvId);
|
|
|
|
let structuresMatch = false;
|
|
if (saved) {
|
|
structuresMatch = savedLayoutMatchesLive(currentLayout, saved as SerializedDockview);
|
|
} else {
|
|
structuresMatch = layoutStructuresMatch(currentLayout, getDefaultLayout());
|
|
}
|
|
|
|
if (!structuresMatch) return null;
|
|
|
|
if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null;
|
|
|
|
const outgoingSessionPanel = api.panels.find(
|
|
(p) => p.id.startsWith("session:") || p.api.component === "chat",
|
|
);
|
|
const outgoingGroupId = outgoingSessionPanel?.group?.id;
|
|
|
|
removeEphemeralPanels(api, activeSessionId);
|
|
|
|
if (activeSessionId && !api.getPanel(`session:${activeSessionId}`)) {
|
|
const sidebarPanel = api.getPanel("sidebar");
|
|
let position: import("dockview-react").AddPanelOptions["position"];
|
|
if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) {
|
|
position = { referenceGroup: outgoingGroupId };
|
|
} else if (sidebarPanel) {
|
|
position = { direction: "right" as const, referencePanel: "sidebar" };
|
|
}
|
|
api.addPanel({
|
|
id: `session:${activeSessionId}`,
|
|
component: "chat",
|
|
tabComponent: "sessionTab",
|
|
title: "Agent",
|
|
params: { sessionId: activeSessionId },
|
|
position,
|
|
});
|
|
}
|
|
|
|
api.layout(params.safeWidth, params.safeHeight);
|
|
return applyLayoutFixups(api);
|
|
}
|
|
|
|
/**
|
|
* Switch the dockview layout between task environments.
|
|
*
|
|
* Uses a fast path when layouts are structurally identical (common case),
|
|
* falling back to a full `api.fromJSON()` rebuild when they differ.
|
|
*
|
|
* The caller is responsible for saving the old env's layout and releasing
|
|
* env-scoped portals before calling this function.
|
|
*/
|
|
export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
|
|
const { api, newEnvId, safeWidth, safeHeight, buildDefault } = params;
|
|
|
|
const fastResult = tryFastEnvSwitch(params);
|
|
if (fastResult) return fastResult;
|
|
|
|
const saved = getHealthyEnvLayout(newEnvId);
|
|
if (saved) {
|
|
try {
|
|
api.fromJSON(saved as SerializedDockview);
|
|
api.layout(safeWidth, safeHeight);
|
|
return applyLayoutFixups(api);
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
buildDefault(api);
|
|
api.layout(safeWidth, safeHeight);
|
|
return applyLayoutFixups(api);
|
|
}
|