"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 { repositorySlug } from "@/lib/repository-slug"; 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, parentTaskId: task.parent_id ?? undefined, 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: mapTaskRepositories(task.repositories), 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(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; workflowNameById: Map; stepTitleById: Map; sessionsByTaskId: Parameters[1]; gitStatusByEnvId: Parameters[2]; envIdBySessionId: Parameters[3]; messagesBySession: Parameters[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, // Carry the parent link so the mobile task switcher nests subtasks the same // way the desktop sidebar does (applyView/TaskSwitcher read parentTaskId). parentTaskId: task.parentTaskId ?? undefined, 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, repositorySlug(repo)]), ), 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; loadTaskSessionsForTask: ( taskId: string, ) => Promise>; 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, parentTaskId: task.parent_id ?? undefined, 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, removeTaskFromBoard: ReturnType["removeTaskFromBoard"], ) { const { deleteTaskById } = useTaskActions(); const [deletingTask, setDeletingTask] = useState<{ id: string; title: string; executorType?: string | null; } | 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", executorType: task?.primaryExecutorType, }); }, [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, primarySessionId: task.primarySessionId, lastSessionByTaskId: state.tasks.lastSessionByTaskId, environmentIdBySessionId: state.environmentIdBySessionId, taskSessionsById: state.taskSessions.items, }); 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; executorType?: string | null; } | 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", executorType: task?.primaryExecutorType, }); }, [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, }; }