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
163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
/**
|
|
* Helpers for task selection in the sidebar. Extracted as pure functions so
|
|
* the no-session fallback path can be unit-tested without standing up the
|
|
* dockview runtime.
|
|
*/
|
|
|
|
import type { StoreApi } from "zustand";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import type { TaskSession } from "@/lib/types/http";
|
|
import {
|
|
performLayoutSwitch,
|
|
releaseLayoutToDefault,
|
|
useDockviewStore,
|
|
} from "@/lib/state/dockview-store";
|
|
import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager";
|
|
import { replaceTaskUrl } from "@/lib/links";
|
|
import { launchSession } from "@/lib/services/session-launch-service";
|
|
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
|
|
|
|
export type SwitchToSessionFn = (
|
|
taskId: string,
|
|
sessionId: string,
|
|
oldSessionId: string | null | undefined,
|
|
) => void;
|
|
|
|
export type FinalizeNoSessionSelectDeps = {
|
|
/** Set the new active task in the kanban store (also clears activeSessionId). */
|
|
setActiveTask: (taskId: string) => void;
|
|
/** Save the outgoing env's layout, release its portals, then build the default layout. */
|
|
releaseLayoutToDefault: (oldEnvId: string | null) => void;
|
|
/** Push the new task id into the URL without reloading. */
|
|
replaceTaskUrl: (taskId: string) => void;
|
|
};
|
|
|
|
/**
|
|
* Finalize a sidebar task selection when no session could be resolved or
|
|
* launched for the new task. Releasing the dockview to default first ensures
|
|
* portal cleanup targets the still-active outgoing env before
|
|
* `setActiveTask` clears `activeSessionId` to null.
|
|
*/
|
|
export function finalizeNoSessionSelect(
|
|
taskId: string,
|
|
oldEnvId: string | null,
|
|
deps: FinalizeNoSessionSelectDeps,
|
|
): void {
|
|
deps.releaseLayoutToDefault(oldEnvId);
|
|
deps.setActiveTask(taskId);
|
|
deps.replaceTaskUrl(taskId);
|
|
}
|
|
|
|
export function resolveLoadedSessionId(
|
|
sessions: TaskSession[],
|
|
preferredSessionId: string,
|
|
): string {
|
|
return (
|
|
sessions.find((s) => s.id === preferredSessionId)?.id ??
|
|
sessions.find((s) => s.is_primary)?.id ??
|
|
sessions[0]?.id ??
|
|
preferredSessionId
|
|
);
|
|
}
|
|
|
|
export function buildSwitchToSession(
|
|
store: StoreApi<AppState>,
|
|
setActiveSession: (taskId: string, sessionId: string) => void,
|
|
): SwitchToSessionFn {
|
|
return (taskId, sessionId, oldSessionId) => {
|
|
const state = store.getState();
|
|
const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null;
|
|
const newEnvId = state.environmentIdBySessionId[sessionId] ?? null;
|
|
setActiveSession(taskId, sessionId);
|
|
if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, sessionId);
|
|
};
|
|
}
|
|
|
|
export async function prepareAndSwitchTask(
|
|
taskId: string,
|
|
store: StoreApi<AppState>,
|
|
switchToSession: SwitchToSessionFn,
|
|
setPreparingTaskId: (id: string | null) => void,
|
|
): Promise<boolean> {
|
|
setPreparingTaskId(taskId);
|
|
// Capture before the async launch; WS events may update activeSessionId
|
|
// before launchSession resolves, causing a layout switch with the wrong old session.
|
|
const oldSessionId = store.getState().tasks.activeSessionId;
|
|
try {
|
|
const { request } = buildPrepareRequest(taskId);
|
|
const resp = await launchSession(request);
|
|
if (resp.session_id) {
|
|
switchToSession(taskId, resp.session_id, oldSessionId);
|
|
if (store.getState().taskPRs.byTaskId[taskId]) {
|
|
const { api, buildDefaultLayout } = useDockviewStore.getState();
|
|
if (api) buildDefaultLayout(api, INTENT_PR_REVIEW);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
setPreparingTaskId(null);
|
|
}
|
|
}
|
|
|
|
export function selectTaskWithLayout(params: {
|
|
taskId: string;
|
|
task: { primarySessionId?: string | null } | undefined;
|
|
store: StoreApi<AppState>;
|
|
switchToSession: SwitchToSessionFn;
|
|
loadTaskSessionsForTask: (taskId: string) => Promise<TaskSession[]>;
|
|
setActiveTask: (taskId: string) => void;
|
|
setPreparingTaskId: (id: string | null) => void;
|
|
}): void {
|
|
const { taskId, task, store, switchToSession, loadTaskSessionsForTask } = params;
|
|
const oldSessionId = store.getState().tasks.activeSessionId;
|
|
if (task?.primarySessionId) {
|
|
const primarySessionId = task.primarySessionId;
|
|
const hasEnvId = !!store.getState().environmentIdBySessionId[primarySessionId];
|
|
if (hasEnvId) {
|
|
switchToSession(taskId, primarySessionId, oldSessionId);
|
|
loadTaskSessionsForTask(taskId);
|
|
replaceTaskUrl(taskId);
|
|
return;
|
|
}
|
|
loadTaskSessionsForTask(taskId).then((sessions) => {
|
|
switchToSession(taskId, resolveLoadedSessionId(sessions, primarySessionId), oldSessionId);
|
|
replaceTaskUrl(taskId);
|
|
});
|
|
return;
|
|
}
|
|
|
|
loadTaskSessionsForTask(taskId).then(async (sessions) => {
|
|
const currentOldSessionId = store.getState().tasks.activeSessionId;
|
|
const primary = sessions.find((s) => s.is_primary);
|
|
const sessionId = primary?.id ?? sessions[0]?.id ?? null;
|
|
if (sessionId) {
|
|
switchToSession(taskId, sessionId, currentOldSessionId);
|
|
replaceTaskUrl(taskId);
|
|
return;
|
|
}
|
|
|
|
const switched = await prepareAndSwitchTask(
|
|
taskId,
|
|
store,
|
|
switchToSession,
|
|
params.setPreparingTaskId,
|
|
);
|
|
if (switched) {
|
|
replaceTaskUrl(taskId);
|
|
return;
|
|
}
|
|
|
|
const currentOldEnvId = currentOldSessionId
|
|
? (store.getState().environmentIdBySessionId[currentOldSessionId] ?? null)
|
|
: null;
|
|
finalizeNoSessionSelect(taskId, currentOldEnvId, {
|
|
setActiveTask: params.setActiveTask,
|
|
releaseLayoutToDefault,
|
|
replaceTaskUrl,
|
|
});
|
|
});
|
|
}
|