391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
'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 (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 gap-1 cursor-pointer"
|
|
onClick={() => setDialogOpen(true)}
|
|
>
|
|
<IconPlus className="h-4 w-4" />
|
|
Task
|
|
</Button>
|
|
<TaskCreateDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
mode="create"
|
|
workspaceId={workspaceId}
|
|
boardId={boardId}
|
|
defaultColumnId={columns[0]?.id ?? null}
|
|
columns={columns}
|
|
onSuccess={onSuccess}
|
|
/>
|
|
</>
|
|
);
|
|
});
|
|
|
|
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<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;
|
|
});
|
|
}
|
|
|
|
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<string | null>(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 = (
|
|
<>
|
|
<TaskSwitcher
|
|
tasks={tasksWithRepositories}
|
|
columns={columns.map((step: KanbanState['steps'][number]) => ({ 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 ? (
|
|
<div className="mt-auto pt-2 border-t border-border/30">
|
|
<BoardSwitcher
|
|
boards={workspaceBoards.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name }))}
|
|
activeBoardId={boardId}
|
|
onSelect={handleBoardChange}
|
|
/>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<PanelRoot>
|
|
<PanelBody className="space-y-4">
|
|
{taskSwitcherContent}
|
|
</PanelBody>
|
|
{boardSwitcher}
|
|
</PanelRoot>
|
|
);
|
|
});
|