Files
kandev/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts
T
João Salavisa 8df1bda87b fix(web): show tasks from all workflows in the mobile task-switcher sheet (#1025)
* fix(web): show tasks from all workflows in the mobile task-switcher sheet

The mobile sheet read tasks via useTasks(workflowId), which gated on
kanban.workflowId === workflowId and returned [] otherwise. On a fresh
/t/[id] load the gate often failed (or the snapshot fetch was still in
flight / silently failing) and the sheet rendered "No tasks yet."

Align mobile with desktop: read from kanbanMulti.snapshots via a new
shared useWorkspaceSidebarTasks hook that does the per-workspace
aggregation (incl. the active-kanban fallback), and pass workflows +
stepsByWorkflowId through so the existing TaskSwitcher grouping kicks
in. The desktop sidebar now delegates to the same hook.

* refactor(web): address review comments on PR #1025

- Extract findTaskInSnapshots to task-select-helpers.ts (shared between
  desktop sidebar and mobile sheet). Mobile passes kanban.tasks as the
  optional fallback so it still locates tasks that arrived via WS before
  the snapshot fetch landed.
- Build workflowNameById from the already-scoped `workflows` array instead
  of the raw, unscoped kanbanMulti.snapshots. Stops the memo from re-running
  on snapshot updates from other workspaces and removes the risk of a stale
  workflow name leaking across a workspace switch. Same fix in both the
  desktop sidebar and the mobile sheet.
- Collapse the duplicated workflow filter in useWorkspaceSidebarTasks into
  a single `filteredWorkflows` memo.

* fix(web): empty scope when workspaceId is unresolved in mobile/desktop sidebar hook

When `useWorkspaceSidebarTasks` was called with a null workspaceId (the
initial SSR / pre-hydration state on /t/[id]) it would widen to every
workflow in the store, briefly leaking snapshots from previously-active
workspaces into the sidebar. Return an empty scope until the workspace
resolves instead, matching the "no cross-workspace bleed" intent of the
original useScopedAggregation comment.

Adds a regression test, plus splits the test file's describe block so the
linter's 100-lines-per-function cap doesn't trip.

* refactor(web): consolidate findTaskInSnapshots and drop unused useSheetData.steps

- Reuse the canonical helper in `lib/kanban/find-task.ts` instead of
  duplicating it in `task-select-helpers.ts`. Extend the canonical one
  with an optional `fallbackTasks` parameter so the mobile sheet can still
  locate tasks via `state.kanban.tasks` when the snapshot fetch hasn't
  landed yet — desktop call sites continue to omit the fallback.
- `useSheetData` was returning the raw active-kanban `steps` slice next
  to the already-mapped `dialogSteps`. The sheet component only consumes
  `dialogSteps`; drop the unused field.

* test(web): add unit tests for findTaskInSnapshots fallback path

* fix(web): pass active-kanban fallback into desktop sidebar task lookups

useWorkspaceSidebarTasks can render tasks from the active kanban slice
before kanbanMulti.snapshots catches up, but the desktop sidebar's
archive/delete/select handlers were searching snapshots only — leaving
fallback-only tasks unresolved during the snapshot race window. Pass
state.kanban.tasks into findTaskInSnapshots in all three handlers so
the dialogs find the right title and selectTaskWithLayout sees the task.

Also trims the multi-line comment in lib/kanban/find-task.ts to a single
line per CLAUDE.md style guidance.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 23:24:35 +01:00

571 lines
20 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { replaceTaskUrl } from "@/lib/links";
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { getSessionInfoForTask } from "@/lib/utils/session-info";
import {
hasPendingClarificationForSession,
hasPendingPermissionForSession,
} from "@/lib/utils/pending-clarification";
import {
repositoryId as toRepositoryId,
type TaskState,
type TaskSessionState,
type Repository,
type Task,
type WorkflowSnapshot,
} from "@/lib/types/http";
import type { KanbanState } from "@/lib/state/slices";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import { resolvePreferredSessionId } from "../task-select-helpers";
// Map workflow snapshot to kanban state on workspace switch.
function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) {
return {
workflowId: newWorkflowId,
isLoading: false,
steps: snapshot.steps.map((step) => ({
id: step.id,
title: step.name,
color: step.color,
position: step.position,
events: step.events,
// Carry optional step capabilities forward so downstream UI doesn't see
// them as missing after a workspace switch (until a full reload).
allow_manual_move: step.allow_manual_move,
prompt: step.prompt,
is_start_step: step.is_start_step,
show_in_command_panel: step.show_in_command_panel,
agent_profile_id: step.agent_profile_id,
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
// Carry the full TaskRepository array so the mobile repo picker
// (useTaskRepoCount + MobileReposSection) keeps working after a
// workspace switch. Without this, the picker silently disappears for
// multi-repo tasks because length defaults to 0.
repositories: task.repositories?.map((r) => ({
id: r.id,
repository_id: r.repository_id,
base_branch: r.base_branch,
checkout_branch: r.checkout_branch,
position: r.position,
})),
primarySessionId: task.primary_session_id ?? undefined,
primarySessionState: task.primary_session_state ?? undefined,
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
primaryExecutorId: task.primary_executor_id ?? undefined,
primaryExecutorType: task.primary_executor_type ?? undefined,
primaryExecutorName: task.primary_executor_name ?? undefined,
isRemoteExecutor: task.is_remote_executor ?? false,
updatedAt: task.updated_at,
})),
};
}
function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[]): T[] {
return [...items].sort((a, b) => {
const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return bDate - aDate;
});
}
type SheetItemCtx = {
repositoryPathsById: Map<string, string | undefined>;
workflowNameById: Map<string, string>;
stepTitleById: Map<string, string>;
sessionsByTaskId: Parameters<typeof getSessionInfoForTask>[1];
gitStatusByEnvId: Parameters<typeof getSessionInfoForTask>[2];
envIdBySessionId: Parameters<typeof getSessionInfoForTask>[3];
messagesBySession: Parameters<typeof hasPendingClarificationForSession>[0];
};
function toSheetItem(
task: KanbanState["tasks"][number] & { _workflowId: string },
ctx: SheetItemCtx,
) {
const sessionInfo = getSessionInfoForTask(
task.id,
ctx.sessionsByTaskId,
ctx.gitStatusByEnvId,
ctx.envIdBySessionId,
);
return {
id: task.id,
title: task.title,
state: task.state as TaskState | undefined,
sessionState:
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined),
description: task.description,
workflowId: task._workflowId,
workflowName: ctx.workflowNameById.get(task._workflowId),
workflowStepId: task.workflowStepId,
workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId),
repositoryPath: task.repositoryId
? ctx.repositoryPathsById.get(toRepositoryId(task.repositoryId))
: undefined,
diffStats: sessionInfo.diffStats,
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
isRemoteExecutor: task.isRemoteExecutor,
remoteExecutorType: task.primaryExecutorType ?? undefined,
remoteExecutorName: task.primaryExecutorName ?? undefined,
primarySessionId: task.primarySessionId ?? null,
hasPendingClarification: hasPendingClarificationForSession(
ctx.messagesBySession,
task.primarySessionId,
),
hasPendingPermission: hasPendingPermissionForSession(
ctx.messagesBySession,
task.primarySessionId,
),
};
}
export function useSheetData(workspaceId: string | null) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionsById = useAppStore((state) => state.taskSessions.items);
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
const messagesBySession = useAppStore((state) => state.messages.bySession);
const {
allTasks,
allSteps,
stepsByWorkflowId,
workflows,
isLoading: tasksLoading,
} = useWorkspaceSidebarTasks(workspaceId);
const steps = useAppStore((state) => state.kanban.steps);
const workspaces = useAppStore((state) => state.workspaces.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const selectedTaskId = useMemo(() => {
if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId;
return activeTaskId;
}, [activeSessionId, activeTaskId, sessionsById]);
const tasksWithRepositories = useMemo(() => {
const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : [];
const ctx: SheetItemCtx = {
repositoryPathsById: new Map(
repositories.map((repo: Repository) => [repo.id, repo.local_path]),
),
workflowNameById: new Map(workflows.map((w) => [w.id, w.name])),
stepTitleById: new Map(allSteps.map((s) => [s.id, s.title])),
sessionsByTaskId,
gitStatusByEnvId,
envIdBySessionId,
messagesBySession,
};
return allTasks.map((task) => toSheetItem(task, ctx));
}, [
repositoriesByWorkspace,
allTasks,
allSteps,
workflows,
workspaceId,
sessionsByTaskId,
gitStatusByEnvId,
envIdBySessionId,
messagesBySession,
]);
const dialogSteps = useMemo(
() =>
steps.map((step: KanbanState["steps"][number]) => ({
id: step.id,
title: step.title,
color: step.color,
events: step.events,
})),
[steps],
);
return {
activeTaskId,
selectedTaskId,
workspaces,
workflows,
stepsByWorkflowId,
// Skeleton while the first snapshot fetch is in flight — otherwise shows "No tasks yet." even when tasks exist.
tasksLoading,
tasksWithRepositories,
dialogSteps,
};
}
type SheetNavOptions = {
workspaceId: string | null;
store: ReturnType<typeof useAppStoreApi>;
loadTaskSessionsForTask: (
taskId: string,
) => Promise<Array<{ id: string; updated_at?: string | null }>>;
setActiveSession: (taskId: string, sessionId: string) => void;
setActiveTask: (taskId: string) => void;
onOpenChange: (open: boolean) => void;
};
async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts;
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } }));
try {
const workflowsResponse = await listWorkflows(newWorkspaceId, {
cache: "no-store",
includeHidden: true,
});
const newWorkspaceWorkflows = workflowsResponse.workflows ?? [];
const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden);
if (!firstWorkflow) {
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } }));
return;
}
const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id);
store.setState((state) => ({
...state,
workflows: {
...state.workflows,
items: [
...state.workflows.items.filter(
(w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId,
),
...newWorkspaceWorkflows.map((w) => ({
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
hidden: w.hidden,
})),
],
activeId: firstWorkflow.id,
},
kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id),
}));
const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0];
if (mostRecentTask) {
const sessions = await loadTaskSessionsForTask(mostRecentTask.id);
const mostRecentSession = sortByUpdatedAtDesc(sessions)[0];
if (mostRecentSession) {
setActiveSession(mostRecentTask.id, mostRecentSession.id);
} else {
setActiveTask(mostRecentTask.id);
}
replaceTaskUrl(mostRecentTask.id);
}
onOpenChange(false);
} catch (error) {
console.error("Failed to switch workspace:", error);
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } }));
}
}
function mapTaskRepositories(
repositories: Task["repositories"],
): KanbanState["tasks"][number]["repositories"] {
return repositories?.map((r) => ({
id: r.id,
repository_id: r.repository_id,
base_branch: r.base_branch,
checkout_branch: r.checkout_branch,
position: r.position,
}));
}
function mergeSessionFields(
task: Task,
existing: KanbanState["tasks"][number] | undefined,
taskSessionId: string | null,
) {
return {
primarySessionId:
taskSessionId ?? task.primary_session_id ?? existing?.primarySessionId ?? undefined,
primarySessionState: task.primary_session_state ?? existing?.primarySessionState ?? undefined,
sessionCount: task.session_count ?? existing?.sessionCount ?? (taskSessionId ? 1 : undefined),
reviewStatus: task.review_status ?? existing?.reviewStatus ?? undefined,
};
}
/**
* Build the kanban-store representation of a task for an upsert. Session-
* derived fields (primarySessionId, sessionCount, etc.) fall through new
* DTO → existing entry → meta.taskSessionId — that way an "edit" call doesn't
* wipe sessions the existing entry carried, and "create with session" still
* sets the primary correctly.
*/
function buildKanbanTaskUpsert(
task: Task,
existing: KanbanState["tasks"][number] | undefined,
meta: { taskSessionId?: string | null } | undefined,
): KanbanState["tasks"][number] {
const taskSessionId = meta?.taskSessionId ?? null;
return {
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
repositories: mapTaskRepositories(task.repositories),
updatedAt: task.updated_at,
...mergeSessionFields(task, existing, taskSessionId),
primaryExecutorId: task.primary_executor_id ?? undefined,
primaryExecutorType: task.primary_executor_type ?? undefined,
primaryExecutorName: task.primary_executor_name ?? undefined,
isRemoteExecutor: task.is_remote_executor ?? false,
};
}
function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) {
const {
workspaceId,
store,
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
onOpenChange,
} = opts;
const handleWorkspaceChange = useCallback(
async (newWorkspaceId: string) => {
if (newWorkspaceId === workspaceId) return;
await switchWorkspace(newWorkspaceId, {
workspaceId,
store,
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
onOpenChange,
});
},
// Spread the individual fields rather than the `opts` object so callers
// re-passing a fresh literal each render don't defeat memoization.
[workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange],
);
const handleTaskCreated = useCallback(
(task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => {
store.setState((state) => {
if (state.kanban.workflowId !== task.workflow_id) return state;
const existing = state.kanban.tasks.find(
(item: KanbanState["tasks"][number]) => item.id === task.id,
);
const nextTask = buildKanbanTaskUpsert(task, existing, meta);
return {
...state,
kanban: {
...state.kanban,
tasks: state.kanban.tasks.some(
(item: KanbanState["tasks"][number]) => item.id === task.id,
)
? state.kanban.tasks.map((item: KanbanState["tasks"][number]) =>
item.id === task.id ? nextTask : item,
)
: [...state.kanban.tasks, nextTask],
},
};
});
setActiveTask(task.id);
if (meta?.taskSessionId) {
setActiveSession(task.id, meta.taskSessionId);
}
replaceTaskUrl(task.id);
onOpenChange(false);
},
[store, setActiveTask, setActiveSession, onOpenChange],
);
return { handleWorkspaceChange, handleTaskCreated };
}
type SelectTaskOptions = {
setActiveTask: (taskId: string) => void;
setActiveSession: (taskId: string, sessionId: string) => void;
loadTaskSessionsForTask: SheetNavOptions["loadTaskSessionsForTask"];
onOpenChange: (open: boolean) => void;
};
async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskOptions) {
const { setActiveTask, setActiveSession, loadTaskSessionsForTask, onOpenChange } = opts;
try {
const sessions = await loadTaskSessionsForTask(taskId);
const sessionId = sessions[0]?.id ?? null;
if (sessionId) {
setActiveSession(taskId, sessionId);
replaceTaskUrl(taskId);
onOpenChange(false);
return;
}
// No session — prepare workspace.
const { request } = buildPrepareRequest(taskId);
try {
const resp = await launchSession(request);
if (resp.session_id) {
setActiveSession(taskId, resp.session_id);
replaceTaskUrl(taskId);
onOpenChange(false);
return;
}
} catch {
// Fall through to default navigation.
}
} catch (error) {
// Loading sessions can reject (network / 5xx). Don't strand the user;
// fall back to plain task navigation so URL + state still align with tap.
console.error("Failed to load sessions for task:", error);
}
setActiveTask(taskId);
replaceTaskUrl(taskId);
onOpenChange(false);
}
function useSheetDeleteActions(
store: ReturnType<typeof useAppStoreApi>,
removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"],
) {
const { deleteTaskById } = useTaskActions();
const [deletingTask, setDeletingTask] = useState<{ id: string; title: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteTask = useCallback(
(taskId: string) => {
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
setDeletingTask({ id: taskId, title: task?.title ?? "this task" });
},
[store],
);
const handleDeleteConfirm = useCallback(
async (opts?: { cascade?: boolean }) => {
if (!deletingTask || isDeleting) return;
const taskId = deletingTask.id;
setIsDeleting(true);
// Capture active state before the async API call — the WS "task.deleted"
// handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs.
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
store.getState().tasks;
try {
await deleteTaskById(taskId, opts);
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
} catch (error) {
console.error("Failed to delete task:", error);
} finally {
setIsDeleting(false);
setDeletingTask(null);
}
},
[deletingTask, isDeleting, deleteTaskById, removeTaskFromBoard, store],
);
const deletingTaskId = isDeleting ? (deletingTask?.id ?? null) : null;
return {
deletingTaskId,
deletingTask,
setDeletingTask,
isDeleting,
handleDeleteTask,
handleDeleteConfirm,
};
}
export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) {
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const store = useAppStoreApi();
const archiveAndSwitch = useArchiveAndSwitchTask();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
const handleSelectTask = useCallback(
(taskId: string) => {
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
if (task?.primarySessionId) {
const targetSessionId = resolvePreferredSessionId(
taskId,
task.primarySessionId,
state.tasks.lastSessionByTaskId,
state.environmentIdBySessionId,
);
setActiveSession(taskId, targetSessionId);
loadTaskSessionsForTask(taskId);
replaceTaskUrl(taskId);
onOpenChange(false);
return;
}
void selectTaskWithoutPrimarySession(taskId, {
setActiveTask,
setActiveSession,
loadTaskSessionsForTask,
onOpenChange,
});
},
[loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange],
);
const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null);
const [isArchiving, setIsArchiving] = useState(false);
const handleArchiveTask = useCallback(
(taskId: string) => {
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
},
[store],
);
const handleArchiveConfirm = useCallback(
async (opts?: { cascade?: boolean }) => {
if (!archivingTask) return;
setIsArchiving(true);
try {
await archiveAndSwitch(archivingTask.id, opts);
} catch (error) {
console.error("Failed to archive task:", error);
} finally {
setIsArchiving(false);
setArchivingTask(null);
}
},
[archivingTask, archiveAndSwitch],
);
const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({
workspaceId,
store,
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
onOpenChange,
});
return {
handleSelectTask,
handleArchiveTask,
handleWorkspaceChange,
handleTaskCreated,
archivingTask,
setArchivingTask,
isArchiving,
handleArchiveConfirm,
...deleteActions,
};
}