Files
kandev/apps/web/components/task/task-select-helpers.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

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,
});
});
}