diff --git a/docs/kanban-overhaul-plan.md b/docs/kanban-overhaul-plan.md new file mode 100644 index 000000000..c5bb8cd5e --- /dev/null +++ b/docs/kanban-overhaul-plan.md @@ -0,0 +1,408 @@ +# Kanban UI Overhaul Plan — Cline-inspired Agent Orchestration Dashboard + +**Branch:** `feat/kanban-ui-overhaul` +**Date:** 2026-03-27 +**Author:** Spacebot planning pass + +--- + +## Visual Reference — Cline Kanban Demo Analysis + +Extracted 38 frames from three demo videos at https://cline.bot/kanban. Key observations: + +### Video 1 — "Watch the board, not the terminals" +- **Overall layout**: A full-width dark-themed kanban board with a collapsible left sidebar. The sidebar contains an AI chat interface labelled "Cline" — a natural-language prompt box at the bottom where the user types project descriptions or commands. +- **Column design**: 4-6 status columns rendered as narrow vertical strips with subtle rounded corners and very low-opacity column backgrounds. Column headers show the status name and a small count badge. +- **Task cards**: Cards have a clean flat design with: + - Title in medium-weight text at top + - A small coloured priority chip (e.g. "High" in amber, "Critical" in red) + - A 1–2 line **live activity preview** below the title showing the last output line from the agent (e.g., "Reading file src/api/tasks.rs…") + - A thin animated progress bar or pulsing dot when the task is actively running +- **Card colours**: Cards are near-black (`#111` / `#0f0f0f`) with a subtle `1px` border. Active/in-progress cards get a faint violet left border accent. +- **Sidebar agent**: Visible typing into a prompt box. The sidebar is ~280px wide, and the board takes the remaining width. A "Break down project" or "Create tasks" button is visible. +- **Animation**: Cards animate smoothly between columns as status changes. New tasks appear with a fade+scale-up transition. + +### Video 2 — "Unblock agents quickly. No more issue hunting." +- **Split-panel layout**: When a task card is clicked, the right half of the viewport becomes a **task detail panel** (roughly 50/50 split). The kanban columns compress to fit the left half. +- **Diff view**: The right panel shows a **unified git diff** with syntax-highlighted additions (green) and deletions (red), rendered inline. Line numbers visible on the left margin. The diff scrolls independently. +- **Agent activity stream**: Below or beside the diff is a real-time agent output feed — tool call names, file paths being read/written, and brief status text scroll upward as the agent works. +- **Inline commenting**: A "Add comment" affordance appears on hover over diff lines (a `+` button on the line number gutter). Clicking opens a small text input inline. Comments are forwarded to the running agent. +- **Toolbar**: Above the diff, a small toolbar shows: branch name, commit SHA, file changed count, and "Request changes" / "Approve" buttons. + +### Video 3 — "Chain dependent tasks, manually or automatically." +- **Task dependency graph**: A small visual graph (DAG) is shown either below the task detail or as a separate view. Tasks are nodes; dependency arrows connect them. Completed tasks show a green check. Blocked tasks (dependency not met) are visually greyed out. +- **Chain creation UI**: Drag a "link" handle from one task card to another to create a dependency. A confirmation popover appears. +- **Auto-chain modal**: A "Break down with auto-commit" button opens a modal where the user describes a project and Cline generates a set of linked tasks with dependency chains visualised before accepting. +- **Parallelization**: Tasks that can run in parallel are shown side-by-side in the graph, not sequentially. A "Max parallelization" label appears. +- **Status propagation**: When a task completes, its downstream dependents automatically move from "Blocked" → "Ready" (animated transition visible in the frames). + +--- + +## Current State of Spacebot Kanban + +File: `interface/src/routes/AgentTasks.tsx` + +### What exists: +- **5-column board**: `pending_approval`, `backlog`, `ready`, `in_progress`, `done` +- **Static cards**: Title, priority badge, subtask progress bar, worker badge +- **Quick actions**: Approve / Execute / Mark Done buttons on card +- **Create task dialog**: Modal form with title, description, priority, status +- **Detail dialog**: Modal overlay showing all task fields, subtasks, metadata; approve/execute/delete/reopen actions +- **SSE reactivity**: `taskEventVersion` counter from `useLiveContext` triggers refetch on `task_updated` events +- **Animation**: `framer-motion` `AnimatePresence` on cards (fade + scale) + +### What's missing: +- No drag-and-drop between columns +- No real-time agent output on cards (worker_id badge only) +- No diff view / worktree viewer +- No inline commenting on diffs +- No task dependency chaining UI +- No sidebar decomposition agent +- No activity timeline / history +- No git UI integration +- No script shortcut buttons +- Visual polish is functional but minimal + +### Backend task API endpoints (Rust/Axum): +``` +GET /agents/tasks → list_tasks +GET /agents/tasks/{number} → get_task +POST /agents/tasks → create_task +PUT /agents/tasks/{number} → update_task +DELETE /agents/tasks/{number} → delete_task +POST /agents/tasks/{number}/approve → approve_task +POST /agents/tasks/{number}/execute → execute_task +GET /events → SSE (task_updated events) +``` + +### Task data model: +```typescript +interface TaskItem { + id: string; + agent_id: string; + task_number: number; + title: string; + description?: string; + status: "pending_approval" | "backlog" | "ready" | "in_progress" | "done"; + priority: "critical" | "high" | "medium" | "low"; + subtasks: { title: string; completed: boolean }[]; + metadata: Record; + source_memory_id?: string; + worker_id?: string; + created_by: string; + approved_at?: string; + approved_by?: string; + completed_at?: string; + created_at: string; + updated_at: string; +} +``` + +--- + +## Proposed Features — Priority Ordered + +### 1. Drag-and-Drop Column Movement (Priority: High | Complexity: M) + +**What**: Drag task cards between status columns. Drop onto a column header or into the card list area. + +**Why**: Core Kanban interaction. Currently users must open the detail dialog and change a dropdown. + +**Files to change**: +- `interface/src/routes/AgentTasks.tsx` — add `@dnd-kit/core` drag context, make `KanbanColumn` a drop target, make `TaskCard` draggable +- No backend changes needed — `updateTask` API already supports status changes + +**New backend endpoints**: None +**Complexity**: M + +**Implementation notes**: +- Use `@dnd-kit/core` + `@dnd-kit/sortable` (already compatible with framer-motion layout animations) +- Optimistic update: move card immediately in local state, call `updateTask` in background +- Animate column count badges on drop + +--- + +### 2. Real-time Agent Activity on Cards (Priority: High | Complexity: M) + +**What**: Show the last agent status line on each in-progress card — e.g., "Reading file src/api/tasks.rs" or "Calling shell tool". + +**Why**: Without this, the board is static while agents work. Watching the board means watching cards come alive. + +**Files to change**: +- `interface/src/routes/AgentTasks.tsx` — `TaskCard` component reads `activeWorkers` and `liveTranscripts` from `useLiveContext` +- `interface/src/hooks/useLiveContext.tsx` — already has `activeWorkers` and `liveTranscripts` (no change needed) + +**New backend endpoints**: None (SSE already carries `tool_started` / `worker_text` events) +**Complexity**: M + +**Implementation notes**: +- Match `task.worker_id` to `activeWorkers[task.worker_id]` to get the live worker +- Display last `liveTranscripts[worker_id]` step as a truncated string +- Add a subtle pulsing dot or spinner on the card while the worker is active +- Fade the activity line in/out using framer-motion + +--- + +### 3. Task Detail Side Panel with Real-time Diff View (Priority: High | Complexity: L) + +**What**: Replace the modal dialog with a slide-in side panel (right 45% of the viewport). The panel shows: + - Task title, status, priority, description, subtasks + - **Real-time git diff** of the associated worktree (fetched and polled while `in_progress`) + - **Agent activity stream** (live tool calls scrolling upward) + - Action buttons: Approve, Execute, Mark Done, Reopen, Delete + +**Why**: The modal is a dead-end — you can't see the board while it's open. The side panel lets users monitor the agent and review changes without context-switching. + +**Files to change**: +- `interface/src/routes/AgentTasks.tsx` — replace `TaskDetailDialog` with `TaskDetailPanel` (side panel, not modal) +- `interface/src/api/client.ts` — add `getWorktreeDiff(agentId, taskNumber)` API call +- New component: `interface/src/components/DiffViewer.tsx` — unified diff renderer with syntax highlighting +- New component: `interface/src/components/AgentActivityFeed.tsx` — scrolling tool call stream + +**New backend endpoints**: +``` +GET /agents/tasks/{number}/diff?agent_id=... +``` +Returns `{ diff: string, files_changed: string[], branch: string }` by running `git diff HEAD` in the task's worktree. + +**Backend files**: +- `src/api/tasks.rs` — add `get_task_diff` handler +- `src/api/server.rs` — register the route + +**Complexity**: L + +--- + +### 4. Inline Comments → Agent (Priority: Medium | Complexity: M) + +**What**: In the diff view, hover over a line to reveal a `+` button in the gutter. Click to open an inline comment input. Submitting the comment sends it as a message to the running worker (if active) or stores it for next execution. + +**Why**: Mirrors the PR review workflow developers already know. Lets you guide the agent without leaving the Kanban board. + +**Files to change**: +- `interface/src/components/DiffViewer.tsx` — add per-line comment affordance +- `interface/src/api/client.ts` — add `commentOnTask(agentId, taskNumber, comment, lineContext)` +- New: `interface/src/components/InlineComment.tsx` + +**New backend endpoints**: +``` +POST /agents/tasks/{number}/comment +Body: { agent_id, comment: string, line_context?: string } +``` +Appends to task `metadata.comments[]` and, if a worker is running for this task, sends the comment as a message to the worker channel. + +**Complexity**: M + +--- + +### 5. Task Dependency Chaining (Priority: Medium | Complexity: L) + +**What**: +- A task can declare `depends_on: number[]` (list of upstream task numbers) +- Blocked tasks (upstream not done) show a "Blocked" visual state on the card +- When upstream completes → downstream auto-transitions from `backlog` to `ready` +- UI: drag a "link handle" from one card to another; or set via detail panel dropdown + +**Why**: Lets users model multi-step projects where agent work must happen in sequence. + +**Files to change**: +- `src/tasks/store.rs` — add `depends_on: Vec` field to `Task` struct +- `src/tasks/store.rs` — after marking a task done, check dependents and auto-advance them +- `src/api/tasks.rs` — include `depends_on` in create/update requests +- `interface/src/routes/AgentTasks.tsx` — blocked state UI, dependency drag handle +- `interface/src/api/client.ts` — expose `depends_on` field + +**New backend endpoints**: None (handled via `depends_on` field in update) +**Complexity**: L + +--- + +### 6. Sidebar Decomposition Agent (Priority: Medium | Complexity: L) + +**What**: A collapsible left sidebar (~280px) with a chat prompt. User types a project description; the Cortex agent breaks it into tasks and creates them all atomically. Results appear on the board in real-time. + +**Why**: This is the "Watch the board, not the terminals" pitch. The sidebar turns the board from a task tracker into an AI-driven planning tool. + +**Files to change**: +- `interface/src/routes/AgentTasks.tsx` — add sidebar toggle, `TaskDecompositionSidebar` component +- New: `interface/src/components/TaskDecompositionSidebar.tsx` +- `interface/src/api/client.ts` — add `decomposeProject(agentId, description)` call + +**New backend endpoints**: +``` +POST /agents/tasks/decompose +Body: { agent_id, description: string } +``` +Spawns a short-lived cortex worker that uses the `update_task` tool to create multiple tasks, then returns the created task IDs. The frontend polls/SSE for new tasks during decomposition. + +**Complexity**: L + +--- + +### 7. Activity Timeline on Task Detail (Priority: Medium | Complexity: S) + +**What**: Below the task description in the detail panel, show a chronological timeline of status changes and agent actions: +``` +[09:42] created by cortex +[09:43] approved by marc +[09:44] worker abc123 started +[09:44] → shell: cargo build +[09:45] → file_write: src/api/tasks.rs +[09:47] worker abc123 completed +[09:47] status → done +``` + +**Why**: Gives full traceability of what happened to a task without digging through logs. + +**Files to change**: +- `src/tasks/store.rs` — add `events: Vec` to Task (or store separately in a `task_events` table) +- `src/api/tasks.rs` — expose events in `get_task` response +- `interface/src/routes/AgentTasks.tsx` — render timeline in detail panel +- `migrations/` — new migration for `task_events` table + +**New backend endpoints**: None (part of `get_task` response) +**Complexity**: S (if appended to metadata) / M (if proper DB table) + +--- + +### 8. Git UI Panel (Priority: Low | Complexity: L) + +**What**: A "Git" tab on the task detail panel showing: +- Current branch and recent commits +- Fetch / Pull / Push buttons +- File status (modified, staged, untracked) + +**Why**: Lets users manage the worktree from the dashboard without switching to a terminal. + +**New backend endpoints**: +``` +GET /agents/tasks/{number}/git/status +GET /agents/tasks/{number}/git/log +POST /agents/tasks/{number}/git/fetch +POST /agents/tasks/{number}/git/push +``` + +**Complexity**: L + +--- + +### 9. Script Shortcut Buttons (Priority: Low | Complexity: S) + +**What**: Configurable quick-run buttons on the task detail panel (e.g., "Run Tests", "Build", "Lint"). Configured per-agent in the agent config. + +**Why**: Common operations should be one click from the board. + +**Files to change**: +- `interface/src/routes/AgentConfig.tsx` — add script shortcuts config section +- `interface/src/routes/AgentTasks.tsx` — render shortcut buttons in task detail +- `src/api/` — route to run a script in the worktree context + +**Complexity**: S + +--- + +### 10. Visual Polish (Priority: High | Complexity: S) + +**What**: +- **Dark theme improvements**: Use `#0a0a0a` board background, `#111` card backgrounds (matches Cline's palette) +- **Column accent lines**: Thin 2px top border on each column header matching the status colour +- **Card hover states**: Lift shadow + border highlight on hover (already partially done) +- **Priority colour coding**: Left border accent on cards (red=critical, amber=high, accent=medium, muted=low) +- **Animated transitions**: Column count badges animate on change (framer-motion `AnimatePresence`) +- **In-progress pulse**: Animated gradient or dot on cards with active workers +- **Empty state illustrations**: SVG placeholder when a column is empty + +**Files to change**: +- `interface/src/routes/AgentTasks.tsx` — card, column, toolbar styling updates +- `interface/src/index.css` or Tailwind config — any new utility classes + +**Complexity**: S + +--- + +## Implementation Phases + +### Phase 1 — Quick Wins (1–2 days) +Goal: Board feels alive and polished. + +| Feature | Files | Effort | +|---|---|---| +| Visual polish — card priority borders, dark palette, hover states | `AgentTasks.tsx` | S | +| Real-time agent activity on cards | `AgentTasks.tsx` + `useLiveContext` (no backend) | M | +| Activity timeline (metadata-based, no DB migration) | `AgentTasks.tsx`, `tasks.rs` | S | +| Drag-and-drop between columns | `AgentTasks.tsx` + `@dnd-kit` | M | + +### Phase 2 — Core Power (3–5 days) +Goal: The board replaces the terminal for monitoring and reviewing. + +| Feature | Files | Effort | +|---|---|---| +| Task detail side panel (replace modal) | `AgentTasks.tsx` | M | +| Real-time diff view | `DiffViewer.tsx`, `tasks.rs` (new route), `client.ts` | L | +| Agent activity feed in panel | `AgentActivityFeed.tsx` | S | +| Inline comments → agent | `DiffViewer.tsx`, `tasks.rs`, `client.ts` | M | + +### Phase 3 — Advanced Orchestration (5–10 days) +Goal: The board becomes an AI-driven project management tool. + +| Feature | Files | Effort | +|---|---|---| +| Task dependency chaining | `tasks store.rs`, `AgentTasks.tsx`, migration | L | +| Sidebar decomposition agent | `TaskDecompositionSidebar.tsx`, backend route | L | +| Git UI panel | Multiple backend routes + frontend tab | L | +| Script shortcut buttons | `AgentConfig.tsx`, new route | S | + +--- + +## Architecture Notes + +### Diff Endpoint Design +The worktree diff endpoint should run in the agent's worktree (git working directory). The `Task` struct should include a `worktree_path` field (or derive it from the worker's sandbox directory). The diff is returned as a raw unified diff string; the frontend renders it using a diff-splitting parser. + +### Dependency Resolution +Task dependency resolution should happen in the task store on every `update_task` call that sets `status = done`. A simple SQL query finds tasks where all `depends_on` references are `done` and their own status is `backlog`, then sets them to `ready` and emits `task_updated` SSE events. + +### Side Panel Layout +The side panel uses CSS `flex` with `basis-[45%]` on the panel and `min-w-0 flex-1` on the board. The board columns compress gracefully. On small screens (<768px), the panel overlays the board (modal-like). + +### DnD Library +`@dnd-kit/core` + `@dnd-kit/sortable` is the correct choice: +- Works with React 18 + framer-motion layout animations +- No global DOM event side-effects +- Supports accessibility (keyboard drag, screen reader announcements) + +--- + +## File Inventory + +### Frontend — Modified +- `interface/src/routes/AgentTasks.tsx` — main kanban route (all phases) +- `interface/src/api/client.ts` — new API calls for diff, comments, decompose +- `interface/src/hooks/useLiveContext.tsx` — expose last agent status text per worker + +### Frontend — New +- `interface/src/components/DiffViewer.tsx` — diff renderer with line-level comments +- `interface/src/components/AgentActivityFeed.tsx` — scrolling real-time tool call stream +- `interface/src/components/TaskDecompositionSidebar.tsx` — AI task breakdown sidebar +- `interface/src/components/InlineComment.tsx` — comment input anchored to a diff line + +### Backend — Modified +- `src/api/tasks.rs` — add diff, comment, decompose, git endpoints +- `src/api/server.rs` — register new routes +- `src/tasks/store.rs` — add `depends_on`, `events` fields; dependency resolution logic + +### Backend — New +- `src/api/git.rs` — git operations on worktree (status, log, fetch, push) +- `migrations/YYYYMMDD_task_events.sql` — task event log table + +--- + +## References + +- Cline Kanban videos: https://cline.bot/kanban + - Section 01: Board overview + sidebar decomposition agent + - Section 02: Diff view + inline commenting + - Section 03: Task dependency chaining +- Spacebot task API: `src/api/tasks.rs` +- Spacebot SSE events: `src/api/system.rs` (events endpoint), `interface/src/hooks/useLiveContext.tsx` +- Current Kanban component: `interface/src/routes/AgentTasks.tsx` diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index be4e0456c..acb92a2b4 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -1,247 +1,1306 @@ -import {useCallback, useEffect, useRef, useState} from "react"; -import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { - api, - type CreateTaskRequest, - type TaskItem, - type TaskStatus, -} from "@/api/client"; -import {useLiveContext} from "@/hooks/useLiveContext"; -import {Button} from "@spacedrive/primitives"; + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + useDroppable, + useDraggable, + closestCorners, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; import { - TaskList, - TaskDetail, - TaskCreateForm, - type Task, - type TaskStatus as UiTaskStatus, - type TaskCreateFormData, -} from "@spacedrive/ai"; + api, + type TaskItem, + type TaskStatus, + type TaskPriority, + type CreateTaskRequest, +} from "@/api/client"; +import { useLiveContext } from "@/hooks/useLiveContext"; +import { Badge } from "@/ui/Badge"; +import { Button } from "@/ui/Button"; import { - GithubMetadataBadges, - getGithubReferences, -} from "@/components/TaskUtils"; - -const TASK_LIMIT = 200; - -export function AgentTasks({agentId}: {agentId: string}) { - const queryClient = useQueryClient(); - const {taskEventVersion} = useLiveContext(); - - const queryKey = ["tasks", agentId]; - - // SSE-driven cache invalidation - const prevVersion = useRef(taskEventVersion); - useEffect(() => { - if (taskEventVersion !== prevVersion.current) { - prevVersion.current = taskEventVersion; - queryClient.invalidateQueries({queryKey}); - } - }, [taskEventVersion, queryKey, queryClient]); - - const {data, isLoading, error} = useQuery({ - queryKey, - queryFn: () => api.listTasks({agent_id: agentId, limit: TASK_LIMIT}), - refetchInterval: 15_000, - }); - - const tasks = (data?.tasks ?? []) as unknown as Task[]; - - const [activeTaskId, setActiveTaskId] = useState(null); - const [collapsedGroups, setCollapsedGroups] = useState>( - () => new Set(), - ); - const [createOpen, setCreateOpen] = useState(false); - - const activeTask = tasks.find((t) => t.id === activeTaskId); - - const invalidate = useCallback( - () => queryClient.invalidateQueries({queryKey}), - [queryClient, queryKey], - ); - - const updateMutation = useMutation({ - mutationFn: ({ - taskNumber, - ...req - }: { - taskNumber: number; - status?: TaskStatus; - complete_subtask?: number; - }) => api.updateTask(taskNumber, req), - onSuccess: () => void invalidate(), - }); - - const approveMutation = useMutation({ - mutationFn: (taskNumber: number) => api.approveTask(taskNumber, "human"), - onSuccess: () => void invalidate(), - }); - - const executeMutation = useMutation({ - mutationFn: (taskNumber: number) => api.executeTask(taskNumber), - onSuccess: () => void invalidate(), - }); - - const deleteMutation = useMutation({ - mutationFn: (taskNumber: number) => api.deleteTask(taskNumber), - onSuccess: () => { - setActiveTaskId(null); - void invalidate(); - }, - }); - - const createMutation = useMutation({ - mutationFn: (req: CreateTaskRequest) => api.createTask(req), - onSuccess: () => { - setCreateOpen(false); - void invalidate(); - }, - }); - - const handleStatusChange = useCallback( - (task: Task, status: UiTaskStatus) => { - const t = task as unknown as TaskItem; - // Route approve/execute through their dedicated endpoints - if (t.status === "pending_approval" && status === "ready") { - approveMutation.mutate(t.task_number); - } else if (t.status === "backlog" && status === "in_progress") { - executeMutation.mutate(t.task_number); - } else { - updateMutation.mutate({taskNumber: t.task_number, status}); - } - }, - [updateMutation, approveMutation, executeMutation], - ); - - const handleDelete = useCallback( - (task: Task) => { - deleteMutation.mutate((task as unknown as TaskItem).task_number); - }, - [deleteMutation], - ); - - const handleSubtaskToggle = useCallback( - (task: Task, index: number, _completed: boolean) => { - updateMutation.mutate({ - taskNumber: (task as unknown as TaskItem).task_number, - complete_subtask: index, - }); - }, - [updateMutation], - ); - - const handleToggleGroup = useCallback((status: UiTaskStatus) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(status)) next.delete(status); - else next.add(status); - return next; - }); - }, []); - - const handleCreate = useCallback( - (formData: TaskCreateFormData) => { - createMutation.mutate({ - owner_agent_id: agentId, - title: formData.title, - description: formData.description || undefined, - priority: formData.priority, - status: "backlog", - }); - }, - [createMutation, agentId], - ); - - return ( -
- {/* List panel */} -
- {/* Toolbar */} -
- - {tasks.length} task{tasks.length !== 1 ? "s" : ""} - - -
- - {/* Create form */} - {createOpen && ( -
- setCreateOpen(false)} - isSubmitting={createMutation.isPending} - /> -
- )} - - {/* Task list */} - {isLoading ? ( -
- Loading tasks... -
- ) : error ? ( -
- Failed to load tasks. -
- {(error as Error).message} -
-
- ) : tasks.length === 0 ? ( -
-
-

No tasks yet.

-

- Create one to get started. -

-
-
- ) : ( -
- setActiveTaskId(task.id)} - onStatusChange={handleStatusChange} - onDelete={handleDelete} - /> -
- )} -
- - {/* Detail panel */} - {activeTask && ( -
- setActiveTaskId(null)} - /> - {/* GitHub metadata (not part of the shared TaskDetail) */} - -
- )} -
- ); + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/ui/Dialog"; +import { Markdown } from "@/components/Markdown"; +import { TaskDependencyGraph } from "@/components/TaskDependencyGraph"; +import { DiffViewer } from "@/components/DiffViewer"; +import { useSpacebotConfig } from "@/hooks/useSpacebotConfig"; +import { formatTimeAgo } from "@/lib/format"; +import { AnimatePresence, motion } from "framer-motion"; +import { cx } from "@/ui/utils"; + +const COLUMNS: { status: TaskStatus; label: string }[] = [ + { status: "pending_approval", label: "Pending Approval" }, + { status: "backlog", label: "Backlog" }, + { status: "ready", label: "Ready" }, + { status: "in_progress", label: "In Progress" }, + { status: "done", label: "Done" }, +]; + +const STATUS_COLORS: Record< + TaskStatus, + "default" | "amber" | "accent" | "violet" | "green" +> = { + pending_approval: "amber", + backlog: "default", + ready: "accent", + in_progress: "violet", + done: "green", +}; + +const PRIORITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +const PRIORITY_COLORS: Record< + TaskPriority, + "red" | "amber" | "default" | "outline" +> = { + critical: "red", + high: "amber", + medium: "default", + low: "outline", +}; + +// Left-border accent colors by priority — applied as a colored left strip +const PRIORITY_BORDER: Record = { + critical: "border-l-red-500", + high: "border-l-amber-500", + medium: "border-l-transparent", + low: "border-l-transparent", +}; + +/** Extract dependency task numbers from metadata */ +function getDependencies(task: TaskItem): number[] { + const deps = task.metadata?.depends_on; + if (Array.isArray(deps)) return deps.filter((n): n is number => typeof n === "number"); + return []; } -function GithubSection({metadata}: {metadata: Record}) { - const refs = getGithubReferences(metadata); - if (refs.length === 0) return null; - - return ( -
-

- GitHub Links -

- -
- ); +/** Check if a task is blocked (has incomplete dependencies) */ +function isBlocked(task: TaskItem, allTasks: TaskItem[]): boolean { + const deps = getDependencies(task); + if (deps.length === 0) return false; + return deps.some((depNum) => { + const depTask = allTasks.find((t) => t.task_number === depNum); + return depTask && depTask.status !== "done"; + }); +} + +// -- GitHub Issue type (from registry API) -- +interface GitHubIssue { + number: number; + title: string; + state: string; + url: string; + repository: string; + labels: string[]; + assignees: string[]; + created_at: string; + updated_at: string; +} + +interface IssuesResponse { + issues: GitHubIssue[]; + repos: string[]; +} + +export function AgentTasks({ agentId }: { agentId: string }) { + const queryClient = useQueryClient(); + const { taskEventVersion, activeWorkers } = useLiveContext(); + const config = useSpacebotConfig(agentId); + + // Invalidate on SSE task events + const prevVersion = useRef(taskEventVersion); + useEffect(() => { + if (taskEventVersion !== prevVersion.current) { + prevVersion.current = taskEventVersion; + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + } + }, [taskEventVersion, agentId, queryClient]); + + const { data, isLoading } = useQuery({ + queryKey: ["tasks", agentId], + queryFn: () => api.listTasks(agentId, { limit: 200 }), + refetchInterval: 15_000, + }); + + // Fetch GitHub issues from registry + const { data: issuesData } = useQuery({ + queryKey: ["registry-issues", agentId], + queryFn: async () => { + const resp = await fetch( + `/api/registry/issues?agent_id=${agentId}&limit=100` + ); + if (!resp.ok) return { issues: [], repos: [] } as IssuesResponse; + return resp.json() as Promise; + }, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const githubIssues = issuesData?.issues ?? []; + const availableRepos = issuesData?.repos ?? []; + + const tasks = data?.tasks ?? []; + + // Project filter + const [projectFilter, setProjectFilter] = useState("all"); + // Show/hide GitHub issues + const [showIssues, setShowIssues] = useState(true); + + // Filter GitHub issues by project + const filteredIssues = projectFilter === "all" + ? githubIssues + : githubIssues.filter((i) => i.repository === projectFilter); + + // Filter tasks by project (if a project filter is active) + const filteredTasks = projectFilter === "all" + ? tasks + : tasks.filter((t) => { + const repo = t.metadata?.github_repository as string | undefined; + return repo === projectFilter; + }); + + // Group filtered tasks by status + const tasksByStatus: Record = { + pending_approval: [], + backlog: [], + ready: [], + in_progress: [], + done: [], + }; + for (const task of filteredTasks) { + tasksByStatus[task.status]?.push(task); + } + + // View mode: "board" (kanban) or "graph" (dependency graph) + const [viewMode, setViewMode] = useState<"board" | "graph">("board"); + // Create task dialog + const [createOpen, setCreateOpen] = useState(false); + // Detail dialog — store task number and derive from live list to stay current. + const [selectedTaskNumber, setSelectedTaskNumber] = useState( + null, + ); + const selectedTask = + selectedTaskNumber !== null + ? (tasks.find((t) => t.task_number === selectedTaskNumber) ?? null) + : null; + + // Track which card is currently being dragged (for DragOverlay) + const [draggingTaskId, setDraggingTaskId] = useState(null); + const draggingTask = draggingTaskId + ? (tasks.find((t) => t.id === draggingTaskId) ?? null) + : null; + + const createMutation = useMutation({ + mutationFn: (request: CreateTaskRequest) => + api.createTask(agentId, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + setCreateOpen(false); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ + taskNumber, + ...request + }: { + taskNumber: number; + status?: TaskStatus; + priority?: TaskPriority; + }) => api.updateTask(agentId, taskNumber, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const approveMutation = useMutation({ + mutationFn: (taskNumber: number) => + api.approveTask(agentId, taskNumber, "human"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const executeMutation = useMutation({ + mutationFn: (taskNumber: number) => api.executeTask(agentId, taskNumber), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (taskNumber: number) => api.deleteTask(agentId, taskNumber), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + setSelectedTaskNumber(null); + }, + }); + + const importIssueMutation = useMutation({ + mutationFn: (issue: GitHubIssue) => + api.createTask(agentId, { + title: `[${issue.repository.split("/").pop()}#${issue.number}] ${issue.title}`, + description: `GitHub: ${issue.url}`, + status: "backlog", + priority: "medium", + metadata: { + github_issue_url: issue.url, + github_issue_number: issue.number, + github_repository: issue.repository, + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + // Track which issues are already imported as tasks + const importedIssueUrls = new Set( + tasks + .map((t) => t.metadata?.github_issue_url as string | undefined) + .filter(Boolean) + ); + + // DnD sensors — require 8px movement to start drag (prevents accidental drags on click) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setDraggingTaskId(event.active.id as string); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setDraggingTaskId(null); + const { active, over } = event; + if (!over) return; + + const taskId = active.id as string; + const newStatus = over.id as TaskStatus; + const task = tasks.find((t) => t.id === taskId); + if (!task || task.status === newStatus) return; + + // Optimistic update: move card immediately in the UI + queryClient.setQueryData(["tasks", agentId], (old: any) => { + if (!old) return old; + return { + ...old, + tasks: old.tasks.map((t: TaskItem) => + t.id === taskId ? { ...t, status: newStatus } : t, + ), + }; + }); + + updateMutation.mutate( + { taskNumber: task.task_number, status: newStatus }, + { + onError: () => { + // Revert on failure + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }, + ); + }, + [tasks, agentId, queryClient, updateMutation], + ); + + // Collect active worker IDs for the current agent + const activeWorkerIds = new Set( + Object.values(activeWorkers) + .filter((w) => (w as any).agentId === agentId) + .map((w) => w.id), + ); + + if (isLoading) { + return ( +
+ Loading tasks... +
+ ); + } + + return ( + +
+ {/* Toolbar */} +
+
+ + {tasks.length} task{tasks.length !== 1 ? "s" : ""} + + {tasksByStatus.pending_approval.length > 0 && ( + + {tasksByStatus.pending_approval.length} pending approval + + )} + {tasksByStatus.in_progress.length > 0 && ( + + {tasksByStatus.in_progress.length} in progress + + )} + {(() => { + const blockedCount = tasks.filter((t) => isBlocked(t, tasks)).length; + return blockedCount > 0 ? ( + + {blockedCount} blocked + + ) : null; + })()} + {tasksByStatus.done.length > 0 && ( + + {tasksByStatus.done.length} done + + )} +
+
+ {/* View Toggle */} +
+ + +
+ {/* Project Filter */} + {availableRepos.length > 0 && ( + + )} + {/* Issues Toggle */} + + +
+
+ + {/* Config Status Bar */} + {config.isReady && ( +
+ {/* Worker Model */} + + {config.workerModel.split("/").pop() ?? "no model"} + + {/* Concurrency */} + = config.maxConcurrentWorkers + ? "bg-amber-500/10 text-amber-400" + : "bg-app-line/30 text-ink-faint" + }`} + title="Active / max concurrent workers" + > + {tasksByStatus.in_progress.length}/{config.maxConcurrentWorkers} workers + + {/* Worktrees */} + + Worktrees {config.useWorktrees ? "ON" : "OFF"} + + {/* Platforms */} + {config.enabledPlatforms.map((p) => ( + + {p} + + ))} + {/* Auth */} + {config.isAnthropic && ( + + Claude Max + + )} +
+ )} + + {/* Dependency Graph View */} + {viewMode === "graph" && ( +
+ setSelectedTaskNumber(num)} + /> +
+ )} + + {/* Kanban Board */} +
+ {COLUMNS.map(({ status, label }) => ( + setSelectedTaskNumber(task.task_number)} + onApprove={(task) => approveMutation.mutate(task.task_number)} + onExecute={(task) => executeMutation.mutate(task.task_number)} + onStatusChange={(task, newStatus) => + updateMutation.mutate({ + taskNumber: task.task_number, + status: newStatus, + }) + } + /> + ))} + + {/* GitHub Issues Column */} + {showIssues && filteredIssues.length > 0 && ( +
+
+ + GitHub Issues + + {filteredIssues.length} +
+
+ {filteredIssues.map((issue) => { + const isImported = importedIssueUrls.has(issue.url); + return ( + + ); + })} +
+
+ )} +
+ + {/* Drag overlay — shows a ghost card while dragging */} + + {draggingTask ? ( + {}} + onApprove={() => {}} + onExecute={() => {}} + onStatusChange={() => {}} + /> + ) : null} + + + {/* Create Dialog */} + setCreateOpen(false)} + onCreate={(request) => createMutation.mutate(request)} + isPending={createMutation.isPending} + /> + + {/* Detail Dialog */} + {selectedTask && ( + setSelectedTaskNumber(null)} + onApprove={() => approveMutation.mutate(selectedTask.task_number)} + onExecute={() => executeMutation.mutate(selectedTask.task_number)} + onDelete={() => deleteMutation.mutate(selectedTask.task_number)} + onStatusChange={(status) => + updateMutation.mutate({ + taskNumber: selectedTask.task_number, + status, + }) + } + /> + )} +
+
+ ); +} + +// -- Kanban Column -- + +function KanbanColumn({ + status, + label, + tasks, + allTasks, + isDragging, + activeWorkerIds, + onSelect, + onApprove, + onExecute, + onStatusChange, +}: { + status: TaskStatus; + label: string; + tasks: TaskItem[]; + allTasks: TaskItem[]; + isDragging: boolean; + activeWorkerIds: Set; + onSelect: (task: TaskItem) => void; + onApprove: (task: TaskItem) => void; + onExecute: (task: TaskItem) => void; + onStatusChange: (task: TaskItem, status: TaskStatus) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+ {/* Column Header */} +
+ + {label} + + {tasks.length} +
+ + {/* Cards */} +
+ + {tasks.map((task) => ( + onSelect(task)} + onApprove={() => onApprove(task)} + onExecute={() => onExecute(task)} + onStatusChange={(newStatus) => onStatusChange(task, newStatus)} + /> + ))} + + {tasks.length === 0 && ( +
+ {isOver ? "Drop here" : "No tasks"} +
+ )} +
+
+ ); +} + +// -- Draggable wrapper for TaskCard -- + +function DraggableTaskCard({ + task, + allTasks, + activeWorkerIds, + onSelect, + onApprove, + onExecute, + onStatusChange, +}: { + task: TaskItem; + allTasks: TaskItem[]; + activeWorkerIds: Set; + onSelect: () => void; + onApprove: () => void; + onExecute: () => void; + onStatusChange: (status: TaskStatus) => void; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: task.id, + }); + + return ( + + + + ); +} + +// -- Task Card -- + +function TaskCard({ + task, + isOverlay = false, + allTasks, + activeWorkerIds, + onSelect, + onApprove, + onExecute, + onStatusChange, +}: { + task: TaskItem; + isOverlay?: boolean; + allTasks: TaskItem[]; + activeWorkerIds: Set; + onSelect: () => void; + onApprove: () => void; + onExecute: () => void; + onStatusChange: (status: TaskStatus) => void; +}) { + const subtasksDone = task.subtasks.filter((s) => s.completed).length; + const subtasksTotal = task.subtasks.length; + const deps = getDependencies(task); + const blocked = isBlocked(task, allTasks); + const isWorkerActive = task.worker_id + ? activeWorkerIds.has(task.worker_id) + : false; + + return ( +
+ {/* Title row */} +
+ + #{task.task_number} {task.title} + + {blocked && ( + + Blocked + + )} +
+ + {/* Dependencies */} + {deps.length > 0 && ( +
+ {deps.map((depNum) => { + const depTask = allTasks.find((t) => t.task_number === depNum); + const depDone = depTask?.status === "done"; + return ( + + {depDone ? "\u2713" : "\u23F3"} #{depNum} + + ); + })} +
+ )} + + {/* Meta row */} +
+ + {PRIORITY_LABELS[task.priority]} + + {subtasksTotal > 0 && ( + + {subtasksDone}/{subtasksTotal} + + )} + {task.worker_id && ( + + {isWorkerActive && ( + + )} + + {"\u2699"} {task.worker_id.slice(0, 8)} + + + )} + {!!task.metadata?.worktree && ( + + {"\uD83C\uDF33"} worktree + + )} + {!!task.metadata?.assigned_agent && ( + + {String(task.metadata.assigned_agent)} + + )} +
+ + {/* Subtask progress bar */} + {subtasksTotal > 0 && ( +
+
+
+ )} + + {/* Quick actions — stop propagation so they don't open the dialog */} + {!isOverlay && ( +
e.stopPropagation()}> + {task.status === "pending_approval" && ( + + )} + {(task.status === "backlog" || task.status === "pending_approval") && ( + + )} + {task.status === "in_progress" && ( + + )} +
+ )} + + {/* Footer: timestamp */} +
+ + {formatTimeAgo(task.updated_at)} + + · + {task.created_by} +
+
+ ); +} + +// -- Create Task Dialog -- + +function CreateTaskDialog({ + open, + onClose, + onCreate, + isPending, +}: { + open: boolean; + onClose: () => void; + onCreate: (request: CreateTaskRequest) => void; + isPending: boolean; +}) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [status, setStatus] = useState("backlog"); + const [dependsOn, setDependsOn] = useState(""); + const [assignedAgent, setAssignedAgent] = useState(""); + + const handleSubmit = useCallback(() => { + if (!title.trim()) return; + const deps = dependsOn + .split(",") + .map((s) => parseInt(s.trim().replace("#", ""), 10)) + .filter((n) => !isNaN(n)); + const metadata: Record = {}; + if (deps.length > 0) metadata.depends_on = deps; + if (assignedAgent.trim()) metadata.assigned_agent = assignedAgent.trim(); + onCreate({ + title: title.trim(), + description: description.trim() || undefined, + priority, + status, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }); + setTitle(""); + setDescription(""); + setPriority("medium"); + setStatus("backlog"); + setDependsOn(""); + setAssignedAgent(""); + }, [title, description, priority, status, dependsOn, assignedAgent, onCreate]); + + return ( + !v && onClose()}> + + + Create Task + +
+
+ + setTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + autoFocus + /> +
+
+ +