'use client'; import { useCallback, useMemo, useState, memo } from 'react'; import type { TaskState, Repository, TaskSession, BoardSnapshot, Task } from '@/lib/types/http'; import type { KanbanState } from '@/lib/state/slices'; import { TaskSwitcher } from './task-switcher'; import { BoardSwitcher } from './board-switcher'; 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 { linkToSession } from '@/lib/links'; import { listTaskSessions, fetchBoardSnapshot } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; import { useTaskActions } from '@/hooks/use-task-actions'; import { performLayoutSwitch } from '@/lib/state/dockview-store'; // Extracted component to isolate dialog state from sidebar type NewTaskButtonProps = { workspaceId: string | null; boardId: string | null; columns: Array<{ id: string; title: string; color?: string; autoStartAgent?: boolean }>; onSuccess: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void; }; export const NewTaskButton = memo(function NewTaskButton({ workspaceId, boardId, columns, onSuccess, }: NewTaskButtonProps) { const [dialogOpen, setDialogOpen] = useState(false); return ( <> ); }); type TaskSessionSidebarProps = { workspaceId: string | null; boardId: string | null; }; // Helper to map board snapshot to kanban state function mapSnapshotToKanban(snapshot: BoardSnapshot, newBoardId: string) { return { boardId: newBoardId, isLoading: false, steps: snapshot.steps.map((step) => ({ id: step.id, title: step.name, color: step.color, position: step.position, autoStartAgent: step.auto_start_agent, })), 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, primarySessionId: task.primary_session_id ?? undefined, sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, updatedAt: task.updated_at, })), }; } // Helper to sort by updated_at descending (most recent first) 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; }); } export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarProps) { 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 { tasks } = useTasks(boardId); const columns = useAppStore((state) => state.kanban.steps); const boards = useAppStore((state) => state.boards.items); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); const selectedTaskId = useMemo(() => { if (activeSessionId) { return sessionsById[activeSessionId]?.task_id ?? activeTaskId; } return activeTaskId; }, [activeSessionId, activeTaskId, sessionsById]); const kanbanBoardId = useAppStore((state) => state.kanban.boardId); const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); // Consider loading if explicitly loading OR if board IDs mismatch (mid-switch) const isLoadingBoard = kanbanIsLoading || (boardId !== null && kanbanBoardId !== boardId); const store = useAppStoreApi(); // Task deletion state and handler const { deleteTaskById } = useTaskActions(); const [deletingTaskId, setDeletingTaskId] = useState(null); // Helper to get session info for a task (diff stats and updatedAt) const getSessionInfoForTask = useCallback( (taskId: string) => { // Get sessions for this task const sessions = sessionsByTaskId[taskId] ?? []; if (sessions.length === 0) return { diffStats: undefined, updatedAt: undefined }; // Find the primary session or the most recent one const primarySession = sessions.find((s: TaskSession) => s.is_primary); const latestSession = primarySession ?? sessions[0]; if (!latestSession) return { diffStats: undefined, updatedAt: undefined }; const updatedAt = latestSession.updated_at; // Get git status for this session const gitStatus = gitStatusBySessionId[latestSession.id]; if (!gitStatus?.files) return { diffStats: undefined, updatedAt }; // Sum up additions and deletions from all files let additions = 0; let deletions = 0; for (const file of Object.values(gitStatus.files)) { additions += file.additions ?? 0; deletions += file.deletions ?? 0; } const diffStats = additions === 0 && deletions === 0 ? undefined : { additions, deletions }; return { diffStats, updatedAt }; }, [sessionsByTaskId, gitStatusBySessionId] ); // Get boards for the current workspace const workspaceBoards = useMemo(() => { if (!workspaceId) return []; return boards.filter((board: { id: string; workspaceId: string; name: string }) => board.workspaceId === workspaceId); }, [boards, workspaceId]); const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : []; const repositoryPathsById = new Map(repositories.map((repo: Repository) => [repo.id, repo.local_path])); return tasks.map((task: KanbanState['tasks'][number]) => { const sessionInfo = getSessionInfoForTask(task.id); return { id: task.id, title: task.title, state: task.state as TaskState | undefined, description: task.description, workflowStepId: task.workflowStepId, repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, diffStats: sessionInfo.diffStats, // Use session's updatedAt if available, otherwise fall back to task's updatedAt updatedAt: sessionInfo.updatedAt ?? task.updatedAt, }; }); }, [repositoriesByWorkspace, tasks, workspaceId, getSessionInfoForTask]); const updateUrl = useCallback((sessionId: string) => { if (typeof window === 'undefined') return; window.history.replaceState({}, '', linkToSession(sessionId)); }, []); const loadTaskSessionsForTask = useCallback( async (taskId: string) => { const state = store.getState(); if (state.taskSessionsByTask.loadedByTaskId[taskId]) { return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; } if (state.taskSessionsByTask.loadingByTaskId[taskId]) { return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; } setTaskSessionsLoading(taskId, true); try { const response = await listTaskSessions(taskId, { cache: 'no-store' }); setTaskSessionsForTask(taskId, response.sessions ?? []); return response.sessions ?? []; } catch (error) { console.error('Failed to load task sessions:', error); setTaskSessionsForTask(taskId, []); return []; } finally { setTaskSessionsLoading(taskId, false); } }, [setTaskSessionsForTask, setTaskSessionsLoading, store] ); const handleDeleteTask = useCallback( async (taskId: string) => { setDeletingTaskId(taskId); try { await deleteTaskById(taskId); // Get remaining tasks after filtering out deleted one const currentTasks = store.getState().kanban.tasks; const remainingTasks = currentTasks.filter( (item: KanbanState['tasks'][number]) => item.id !== taskId ); // Update UI after successful delete store.setState((state) => ({ ...state, kanban: { ...state.kanban, tasks: remainingTasks, }, })); // If deleted task was active, switch to another task or go home const currentActiveTaskId = store.getState().tasks.activeTaskId; if (currentActiveTaskId === taskId) { const oldSessionId = store.getState().tasks.activeSessionId; if (remainingTasks.length > 0) { // Switch to the first remaining task const nextTask = remainingTasks[0]; // Use handleSelectTask pattern - check for primarySessionId first if (nextTask.primarySessionId) { setActiveSession(nextTask.id, nextTask.primarySessionId); performLayoutSwitch(oldSessionId, nextTask.primarySessionId); window.history.replaceState({}, '', linkToSession(nextTask.primarySessionId)); } else { // Load sessions to find one to navigate to const sessions = await loadTaskSessionsForTask(nextTask.id); const sessionId = sessions[0]?.id ?? null; if (sessionId) { setActiveSession(nextTask.id, sessionId); performLayoutSwitch(oldSessionId, sessionId); window.history.replaceState({}, '', linkToSession(sessionId)); } else { setActiveTask(nextTask.id); } } } else { // No tasks left, go to homepage window.location.href = '/'; } } } finally { setDeletingTaskId(null); } }, [deleteTaskById, store, setActiveSession, setActiveTask, loadTaskSessionsForTask] ); const handleSelectTask = useCallback( (taskId: string) => { const oldSessionId = store.getState().tasks.activeSessionId; // Get task from kanban state to check for primarySessionId const kanbanTasks = store.getState().kanban.tasks; const task = kanbanTasks.find((t) => t.id === taskId); // If task has primarySessionId, switch immediately (instant UX) if (task?.primarySessionId) { setActiveSession(taskId, task.primarySessionId); performLayoutSwitch(oldSessionId, task.primarySessionId); updateUrl(task.primarySessionId); // Load sessions in background to update cache (non-blocking) loadTaskSessionsForTask(taskId); return; } // Fallback: load sessions first if no primarySessionId loadTaskSessionsForTask(taskId).then((sessions) => { const currentOldSessionId = store.getState().tasks.activeSessionId; const sessionId = sessions[0]?.id ?? null; if (!sessionId) { setActiveTask(taskId); return; } setActiveSession(taskId, sessionId); performLayoutSwitch(currentOldSessionId, sessionId); updateUrl(sessionId); }); }, [loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, store] ); // Handle board change - fetch new board data and navigate to most recent task's session const handleBoardChange = useCallback( async (newBoardId: string) => { if (newBoardId === boardId) return; // Set loading state in store (synchronous) store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true }, })); try { const snapshot = await fetchBoardSnapshot(newBoardId); // Update the kanban state with the new board data store.setState((state) => ({ ...state, kanban: mapSnapshotToKanban(snapshot, newBoardId), boards: { ...state.boards, activeId: newBoardId }, })); // Navigate to the most recent task's most recent session const oldSessionId = store.getState().tasks.activeSessionId; 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); performLayoutSwitch(oldSessionId, mostRecentSession.id); updateUrl(mostRecentSession.id); } else { setActiveTask(mostRecentTask.id); } } } catch (error) { console.error('Failed to switch board:', error); store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false }, })); } }, [boardId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl] ); const taskSwitcherContent = ( <> ({ id: step.id, title: step.title, color: step.color }))} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={(taskId) => { handleSelectTask(taskId); }} onDeleteTask={handleDeleteTask} deletingTaskId={deletingTaskId} isLoading={isLoadingBoard} /> ); const boardSwitcher = workspaceBoards.length > 1 ? (
({ id: b.id, name: b.name }))} activeBoardId={boardId} onSelect={handleBoardChange} />
) : null; return ( {taskSwitcherContent} {boardSwitcher} ); });