"use client"; import { useCallback, useMemo, useState, memo } from "react"; import type { TaskState, TaskSessionState, Repository, Task } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; import { TaskSwitcher } from "./task-switcher"; import { TaskRenameDialog } from "./task-rename-dialog"; import { Button } from "@kandev/ui/button"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { IconPlus } from "@tabler/icons-react"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { replaceSessionUrl } from "@/lib/links"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useTaskActions } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { performLayoutSwitch, useDockviewStore } from "@/lib/state/dockview-store"; import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; import { useArchivedTaskState } from "./task-archived-context"; /** Find a task across all workflow snapshots */ function findTaskInSnapshots( snapshots: Record, taskId: string, ): KanbanState["tasks"][number] | undefined { for (const snapshot of Object.values(snapshots)) { const task = snapshot.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId); if (task) return task; } return undefined; } // Extracted component to isolate dialog state from sidebar type NewTaskButtonProps = { workspaceId: string | null; workflowId: string | null; steps: Array<{ id: string; title: string; color?: string; events?: { on_enter?: Array<{ type: string; config?: Record }>; on_turn_complete?: Array<{ type: string; config?: Record }>; }; }>; onSuccess: ( task: Task, mode: "create" | "edit", meta?: { taskSessionId?: string | null }, ) => void; }; export const NewTaskButton = memo(function NewTaskButton({ workspaceId, workflowId, steps, onSuccess, }: NewTaskButtonProps) { const [dialogOpen, setDialogOpen] = useState(false); return ( <> ); }); type TaskSessionSidebarProps = { workspaceId: string | null; workflowId: string | null; }; function useSidebarData(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 gitStatusBySessionId = useAppStore((state) => state.gitStatus.bySessionId); const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId); const archivedState = useArchivedTaskState(); const selectedTaskId = useMemo(() => { if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; return activeTaskId; }, [activeSessionId, activeTaskId, sessionsById]); const isLoadingWorkflow = isMultiLoading && Object.keys(snapshots).length === 0; const { allTasks, allSteps } = useMemo(() => { const tasks: KanbanState["tasks"] = []; const stepMap = new Map< string, { id: string; title: string; color: string; position: number } >(); for (const snapshot of Object.values(snapshots)) { for (const step of snapshot.steps) { if (!stepMap.has(step.id)) stepMap.set(step.id, step); } // Ephemeral tasks are already filtered out by useAllWorkflowSnapshots tasks.push(...snapshot.tasks); } const sortedSteps = [...stepMap.values()].sort((a, b) => a.position - b.position); return { allTasks: tasks, allSteps: sortedSteps }; }, [snapshots]); const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; const repositorySlugById = new Map( repositories.map((repo: Repository) => [ repo.id, repo.provider_owner && repo.provider_name ? `${repo.provider_owner}/${repo.provider_name}` : repo.local_path, ]), ); const items = allTasks.map((task: KanbanState["tasks"][number]) => { const sessionInfo = getSessionInfoForTask(task.id, sessionsByTaskId, gitStatusBySessionId); const resolvedSessionState = sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined); const repoSlug = task.repositoryId ? repositorySlugById.get(task.repositoryId) : undefined; const pr = taskPRsByTaskId[task.id]; return { id: task.id, title: task.title, state: task.state as TaskState | undefined, sessionState: resolvedSessionState, description: task.description, workflowStepId: task.workflowStepId as string | undefined, repositoryPath: pr ? `${pr.owner}/${pr.repo}` : repoSlug, diffStats: sessionInfo.diffStats, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorType: task.primaryExecutorType ?? undefined, remoteExecutorName: task.primaryExecutorName ?? undefined, primarySessionId: task.primarySessionId ?? null, updatedAt: sessionInfo.updatedAt ?? task.updatedAt, isArchived: false as boolean, }; }); if ( archivedState.isArchived && archivedState.archivedTaskId && !items.some((t) => t.id === archivedState.archivedTaskId) ) { items.unshift({ id: archivedState.archivedTaskId, title: archivedState.archivedTaskTitle ?? "Archived task", state: undefined, sessionState: undefined, description: undefined, workflowStepId: undefined, repositoryPath: archivedState.archivedTaskRepositoryPath, diffStats: undefined, isRemoteExecutor: false, remoteExecutorType: undefined, remoteExecutorName: undefined, primarySessionId: null, updatedAt: archivedState.archivedTaskUpdatedAt, isArchived: true, }); } return items; }, [ repositoriesByWorkspace, allTasks, workspaceId, sessionsByTaskId, gitStatusBySessionId, taskPRsByTaskId, archivedState, ]); return { activeTaskId, selectedTaskId, allSteps, isLoadingWorkflow, tasksWithRepositories }; } type SwitchFn = ( taskId: string, sessionId: string, oldSessionId: string | null | undefined, ) => void; type StoreApi = ReturnType; async function prepareAndSwitchTask( taskId: string, store: StoreApi, switchToSession: SwitchFn, setPreparingTaskId: (id: string | null) => void, ): Promise { setPreparingTaskId(taskId); try { const { request } = buildPrepareRequest(taskId); const resp = await launchSession(request); if (resp.session_id) { const oldSessionId = store.getState().tasks.activeSessionId; switchToSession(taskId, resp.session_id, oldSessionId); // Apply PR review layout if the task has PR metadata 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); } } function useSidebarActions(store: StoreApi) { const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const [deletingTaskId, setDeletingTaskId] = useState(null); const [preparingTaskId, setPreparingTaskId] = useState(null); const { deleteTaskById, archiveTaskById, renameTaskById } = useTaskActions(); const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store, useLayoutSwitch: true, }); const switchToSession = useCallback( (taskId: string, sessionId: string, oldSessionId: string | null | undefined) => { setActiveSession(taskId, sessionId); performLayoutSwitch(oldSessionId ?? null, sessionId); replaceSessionUrl(sessionId); }, [setActiveSession], ); const handleSelectTask = useCallback( (taskId: string) => { const oldSessionId = store.getState().tasks.activeSessionId; const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId); if (task?.primarySessionId) { switchToSession(taskId, task.primarySessionId, oldSessionId); loadTaskSessionsForTask(taskId); return; } loadTaskSessionsForTask(taskId).then(async (sessions) => { const currentOldSessionId = store.getState().tasks.activeSessionId; const sessionId = sessions[0]?.id ?? null; if (sessionId) { switchToSession(taskId, sessionId, currentOldSessionId); return; } // No session — prepare workspace and switch to it const switched = await prepareAndSwitchTask( taskId, store, switchToSession, setPreparingTaskId, ); if (!switched) setActiveTask(taskId); }); }, [loadTaskSessionsForTask, switchToSession, setActiveTask, store], ); const handleArchiveTask = useCallback( async (taskId: string) => { // Capture active state before the async API call — the WS "task.updated" // handler removes the archived task from kanbans before removeTaskFromBoard runs. const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = store.getState().tasks; try { await archiveTaskById(taskId); await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } catch (error) { console.error("Failed to archive task:", error); } }, [archiveTaskById, removeTaskFromBoard, store], ); const handleDeleteTask = useCallback( async (taskId: string) => { setDeletingTaskId(taskId); // 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); await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); } finally { setDeletingTaskId(null); } }, [deleteTaskById, removeTaskFromBoard, store], ); const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); const handleRenameTask = useCallback((taskId: string, currentTitle: string) => { setRenamingTask({ id: taskId, title: currentTitle }); }, []); const handleRenameSubmit = useCallback( async (newTitle: string) => { if (!renamingTask) return; try { await renameTaskById(renamingTask.id, newTitle); } catch (error) { console.error("Failed to rename task:", error); } setRenamingTask(null); }, [renamingTask, renameTaskById], ); return { deletingTaskId, preparingTaskId, handleSelectTask, handleArchiveTask, handleDeleteTask, renamingTask, setRenamingTask, handleRenameTask, handleRenameSubmit, }; } export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, }: TaskSessionSidebarProps) { const store = useAppStoreApi(); useAllWorkflowSnapshots(workspaceId); const { activeTaskId, selectedTaskId, allSteps, isLoadingWorkflow, tasksWithRepositories } = useSidebarData(workspaceId); const { deletingTaskId, preparingTaskId, handleSelectTask, handleArchiveTask, handleDeleteTask, renamingTask, setRenamingTask, handleRenameTask, handleRenameSubmit, } = useSidebarActions(store); const displayTasks = useMemo( () => preparingTaskId ? tasksWithRepositories.map((t) => t.id === preparingTaskId ? { ...t, sessionState: "STARTING" as TaskSessionState } : t, ) : tasksWithRepositories, [tasksWithRepositories, preparingTaskId], ); return ( ({ id: step.id, title: step.title, color: step.color }))} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={handleSelectTask} onRenameTask={handleRenameTask} onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} /> { if (!open) setRenamingTask(null); }} currentTitle={renamingTask?.title ?? ""} onSubmit={handleRenameSubmit} /> ); });