Files
kandev/apps/web/hooks/use-task-removal.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

206 lines
7.3 KiB
TypeScript

import { useCallback } from "react";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { KanbanState } from "@/lib/state/slices";
import type { TaskSession } from "@/lib/types/http";
import { replaceTaskUrl } from "@/lib/links";
import { listTaskSessions } from "@/lib/api";
import { performLayoutSwitch } from "@/lib/state/dockview-store";
type TaskRemovalOptions = {
store: StoreApi<AppState>;
/** Whether to call performLayoutSwitch when switching sessions (desktop sidebar uses this) */
useLayoutSwitch?: boolean;
};
type RemoveFromBoardOptions = {
/**
* The active task ID captured **before** the async delete API call.
* Avoids a race with the WS "task.deleted" handler that may clear
* activeTaskId before removeTaskFromBoard runs.
*/
wasActiveTaskId?: string | null;
/** The active session ID captured before the async delete API call. */
wasActiveSessionId?: string | null;
};
function cachedSessionsHaveEnvIds(sessions: TaskSession[]): boolean {
return sessions.length === 0 || sessions.every((session) => !!session.task_environment_id);
}
async function loadTaskSessionsForTaskFromStore(
store: StoreApi<AppState>,
taskId: string,
): Promise<TaskSession[]> {
const state = store.getState();
const cachedSessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? [];
if (state.taskSessionsByTask.loadedByTaskId[taskId]) {
if (cachedSessionsHaveEnvIds(cachedSessions)) return cachedSessions;
}
if (state.taskSessionsByTask.loadingByTaskId[taskId]) {
return cachedSessions;
}
store.getState().setTaskSessionsLoading(taskId, true);
try {
const response = await listTaskSessions(taskId, { cache: "no-store" });
store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []);
return response.sessions ?? [];
} catch (error) {
console.error("Failed to load task sessions:", error);
store.getState().setTaskSessionsForTask(taskId, []);
return [];
} finally {
store.getState().setTaskSessionsLoading(taskId, false);
}
}
function removeTaskFromSnapshots(store: StoreApi<AppState>, taskId: string): void {
const currentSnapshots = store.getState().kanbanMulti.snapshots;
for (const [wfId, snapshot] of Object.entries(currentSnapshots)) {
const hadTask = snapshot.tasks.some((t: KanbanState["tasks"][number]) => t.id === taskId);
if (hadTask) {
store.getState().setWorkflowSnapshot(wfId, {
...snapshot,
tasks: snapshot.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId),
});
}
}
const currentKanbanTasks = store.getState().kanban.tasks;
if (currentKanbanTasks.some((t: KanbanState["tasks"][number]) => t.id === taskId)) {
store.setState((state) => ({
...state,
kanban: {
...state.kanban,
tasks: state.kanban.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId),
},
}));
}
}
function collectRemainingTasks(store: StoreApi<AppState>): KanbanState["tasks"] {
const allRemainingTasks: KanbanState["tasks"] = [];
for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) {
allRemainingTasks.push(...snapshot.tasks);
}
if (allRemainingTasks.length === 0) {
allRemainingTasks.push(...store.getState().kanban.tasks);
}
return allRemainingTasks;
}
function switchToSessionForTask(params: {
store: StoreApi<AppState>;
nextTask: KanbanState["tasks"][number];
sessionId: string;
oldEnvId: string | null;
useLayoutSwitch: boolean;
}): void {
const { store, nextTask, sessionId, oldEnvId, useLayoutSwitch } = params;
store.getState().setActiveSession(nextTask.id, sessionId);
if (!useLayoutSwitch) return;
const newEnvId = store.getState().environmentIdBySessionId[sessionId] ?? null;
if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, sessionId);
}
async function switchToNextTask(params: {
store: StoreApi<AppState>;
nextTask: KanbanState["tasks"][number];
oldEnvId: string | null;
useLayoutSwitch: boolean;
loadTaskSessionsForTask: (taskId: string) => Promise<TaskSession[]>;
}): Promise<void> {
const { store, nextTask, oldEnvId, useLayoutSwitch, loadTaskSessionsForTask } = params;
if (nextTask.primarySessionId) {
if (useLayoutSwitch && !store.getState().environmentIdBySessionId[nextTask.primarySessionId]) {
await loadTaskSessionsForTask(nextTask.id);
}
switchToSessionForTask({
store,
nextTask,
sessionId: nextTask.primarySessionId,
oldEnvId,
useLayoutSwitch,
});
replaceTaskUrl(nextTask.id);
return;
}
const sessions = await loadTaskSessionsForTask(nextTask.id);
const sessionId = sessions[0]?.id ?? null;
if (sessionId) {
switchToSessionForTask({ store, nextTask, sessionId, oldEnvId, useLayoutSwitch });
} else {
store.getState().setActiveTask(nextTask.id);
}
replaceTaskUrl(nextTask.id);
}
function resolveOldEnvId(store: StoreApi<AppState>, opts?: RemoveFromBoardOptions): string | null {
const oldSessionId =
opts?.wasActiveSessionId !== undefined
? opts.wasActiveSessionId
: store.getState().tasks.activeSessionId;
return oldSessionId ? (store.getState().environmentIdBySessionId[oldSessionId] ?? null) : null;
}
function resolveActiveTaskId(
store: StoreApi<AppState>,
opts?: RemoveFromBoardOptions,
): string | null {
return opts?.wasActiveTaskId !== undefined
? opts.wasActiveTaskId
: store.getState().tasks.activeTaskId;
}
/**
* Hook that provides shared logic for removing a task from the kanban board
* (after archive or delete) and switching to the next available task.
*
* Used by both TaskSessionSidebar and SessionTaskSwitcherSheet.
*/
export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOptions) {
const loadTaskSessionsForTask = useCallback(
(taskId: string) => loadTaskSessionsForTaskFromStore(store, taskId),
[store],
);
/**
* Remove a task from the kanban board state (both single and multi snapshots)
* and switch to the next available task if the removed task was active.
*
* Pass `opts.wasActiveTaskId` / `opts.wasActiveSessionId` when calling after
* an async API call (e.g. deleteTaskById) — the WS "task.deleted" handler may
* clear activeTaskId before this function runs.
*/
const removeTaskFromBoard = useCallback(
async (taskId: string, opts?: RemoveFromBoardOptions) => {
removeTaskFromSnapshots(store, taskId);
const allRemainingTasks = collectRemainingTasks(store);
// Use the caller-provided active task ID (captured before the async API
// call) to avoid the race with the WS handler that may have already
// cleared it. Fall back to the current store value for callers that
// don't provide it (e.g. archive, which doesn't go through the API).
const activeTaskId = resolveActiveTaskId(store, opts);
if (activeTaskId !== taskId) return;
const oldEnvId = resolveOldEnvId(store, opts);
if (allRemainingTasks.length > 0) {
await switchToNextTask({
store,
nextTask: allRemainingTasks[0],
oldEnvId,
useLayoutSwitch,
loadTaskSessionsForTask,
});
} else {
window.location.href = "/";
}
},
[store, useLayoutSwitch, loadTaskSessionsForTask],
);
return { removeTaskFromBoard, loadTaskSessionsForTask };
}