From 9bd1047e59d99404b14ab62e38528d639e1e651c Mon Sep 17 00:00:00 2001 From: Rod-fernandez Date: Sat, 4 Jul 2026 02:14:39 -0400 Subject: [PATCH 1/6] feat(desktop): add Kanban board UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board overlay (CronView pattern) over the existing /api/plugins/kanban backend: 8 dnd-kit columns in BOARD_COLUMNS order, card drawer with comments/events/runs/worker-log/attachments/DAG links, new-card dialog, board switcher, dispatcher nudge. - HOME-scoped by design: no profileScoped()/?profile= on kanban routes. - Server-authoritative mutations: optimistic moves reconcile from the server response (rule bounces like done→review render where the server put them, with an info toast); 409s revert with the backend's detail. - 'running' is never a drag target (backend 400s direct PATCH; ready→ running only via dispatcher claim) — Nudge dispatcher instead. - 4s visibility-gated polling with latest_event_id cursor (atom identity preserved when unchanged); overlay wrapped in its own ErrorBoundary. - parseKanbanApiError unwraps the Electron IPC error prefix so HTTP status/detail survive the bridge. - i18n en+zh (+types); ja/zh-hant fall back to English. - 22 vitest cases (store rules, move/revert/bounce, cursor identity, component smoke via mocked hermes:api). Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/chat/sidebar/index.tsx | 6 +- .../desktop/src/app/command-palette/index.tsx | 10 + apps/desktop/src/app/desktop-controller.tsx | 9 + apps/desktop/src/app/hooks/use-keybinds.ts | 2 + apps/desktop/src/app/kanban/card-drawer.tsx | 547 ++++++++++++++++++ apps/desktop/src/app/kanban/index.test.tsx | 159 +++++ apps/desktop/src/app/kanban/index.tsx | 441 ++++++++++++++ .../src/app/kanban/new-card-dialog.tsx | 245 ++++++++ apps/desktop/src/app/routes.ts | 7 +- .../app/shell/hooks/use-overlay-routing.ts | 2 + apps/desktop/src/app/types.ts | 9 +- apps/desktop/src/i18n/en.ts | 79 ++- apps/desktop/src/i18n/types.ts | 65 +++ apps/desktop/src/i18n/zh.ts | 79 ++- apps/desktop/src/kanban-api.ts | 179 ++++++ apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/lib/keybinds/actions.ts | 1 + apps/desktop/src/store/kanban.test.ts | 336 +++++++++++ apps/desktop/src/store/kanban.ts | 231 ++++++++ apps/desktop/src/types/kanban.ts | 229 ++++++++ 20 files changed, 2632 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/kanban/card-drawer.tsx create mode 100644 apps/desktop/src/app/kanban/index.test.tsx create mode 100644 apps/desktop/src/app/kanban/index.tsx create mode 100644 apps/desktop/src/app/kanban/new-card-dialog.tsx create mode 100644 apps/desktop/src/kanban-api.ts create mode 100644 apps/desktop/src/store/kanban.test.ts create mode 100644 apps/desktop/src/store/kanban.ts create mode 100644 apps/desktop/src/types/kanban.ts diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427c..b97a1c0e5f5ba 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -95,7 +95,7 @@ import { setCurrentCwd } from '@/store/session' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { type AppView, ARTIFACTS_ROUTE, KANBAN_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' import type { SidebarNavItem } from '../../types' import { countLabel } from './chrome' @@ -144,7 +144,8 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ route: SKILLS_ROUTE }, { id: 'messaging', label: '', icon: props => , route: MESSAGING_ROUTE }, - { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE } + { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE }, + { id: 'kanban', label: '', icon: props => , route: KANBAN_ROUTE } ] // Two modes via the `compact` height variant (styles.css): @@ -1051,6 +1052,7 @@ export function ChatSidebar({ const isInteractive = Boolean(item.action) || Boolean(item.route) const active = + (item.id === 'kanban' && currentView === 'kanban') || (item.id === 'skills' && currentView === 'skills') || (item.id === 'messaging' && currentView === 'messaging') || (item.id === 'artifacts' && currentView === 'artifacts') diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index be89ebb4e128a..859c3ae11d2c3 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -25,6 +25,7 @@ import { Globe, type IconComponent, Info, + Kanban, KeyRound, Layers3, MessageCircle, @@ -68,6 +69,7 @@ import { ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, CRON_ROUTE, + KANBAN_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, PROFILES_ROUTE, @@ -453,6 +455,14 @@ export function CommandPalette() { label: t.shell.statusbar.cron, run: go(CRON_ROUTE) }, + { + action: 'nav.kanban', + icon: Kanban, + id: 'nav-kanban', + keywords: ['kanban', 'board', 'tasks', 'cards', 'dispatcher'], + label: t.kanban.title, + run: go(KANBAN_ROUTE) + }, { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }, { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 4b8c249bced68..0695a15638dc1 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -131,6 +131,7 @@ const AgentsView = lazy(async () => ({ default: (await import('./agents')).Agent const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const KanbanView = lazy(async () => ({ default: (await import('./kanban')).KanbanView })) const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) @@ -221,6 +222,7 @@ export function DesktopController() { commandCenterOpen, cronOpen, currentView, + kanbanOpen, openAgents, openCommandCenterSection, openStarmap, @@ -1118,6 +1120,12 @@ export function DesktopController() { )} + {kanbanOpen && ( + + + + )} + {profilesOpen && ( @@ -1338,6 +1346,7 @@ export function DesktopController() { path="artifacts" /> + diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 379c719ad0363..68dbb4ceafad7 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -50,6 +50,7 @@ import { AGENTS_ROUTE, ARTIFACTS_ROUTE, CRON_ROUTE, + KANBAN_ROUTE, MESSAGING_ROUTE, PROFILES_ROUTE, sessionRoute, @@ -128,6 +129,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'nav.messaging': () => navigate(MESSAGING_ROUTE), 'nav.artifacts': () => navigate(ARTIFACTS_ROUTE), 'nav.cron': () => navigate(CRON_ROUTE), + 'nav.kanban': () => navigate(KANBAN_ROUTE), 'nav.agents': () => navigate(AGENTS_ROUTE), 'session.new': () => { diff --git a/apps/desktop/src/app/kanban/card-drawer.tsx b/apps/desktop/src/app/kanban/card-drawer.tsx new file mode 100644 index 0000000000000..9814e2657fda9 --- /dev/null +++ b/apps/desktop/src/app/kanban/card-drawer.tsx @@ -0,0 +1,547 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { type Translations, useI18n } from '@/i18n' +import { addKanbanComment, getKanbanAssignees, getKanbanTask, getKanbanTaskLog, updateKanbanTask } from '@/kanban-api' +import { cn } from '@/lib/utils' +import { + $kanbanActiveBoard, + $kanbanBoard, + allowedDropColumns, + applyKanbanTaskPatch, + refreshKanbanBoard, + setKanbanSelectedTaskId +} from '@/store/kanban' +import { notify, notifyError } from '@/store/notifications' +import type { KanbanRun, KanbanTaskDetail, KanbanUpdateTaskPayload } from '@/types/kanban' + +import { PanelBlock, PanelPill, type PanelPillTone, PanelSectionLabel } from '../overlays/panel' + +// Sentinel for the Radix Select "unassigned" row (empty item values are not +// allowed); mapped to `assignee: ''` on PATCH, which unassigns server-side. +const UNASSIGNED = '__unassigned__' + +const RUN_TONE: Record = { + running: 'good', + done: 'good', + blocked: 'warn', + released: 'muted', + reclaimed: 'muted', + crashed: 'bad', + failed: 'bad', + timed_out: 'bad' +} + +function formatEpoch(seconds?: null | number): string { + if (!seconds) { + return '—' + } + + const date = new Date(seconds * 1000) + + return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString() +} + +function formatBytes(size?: null | number): string { + if (size === null || size === undefined) { + return '—' + } + + if (size < 1024) { + return `${size} B` + } + + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +interface KanbanCardDrawerProps { + onClose: () => void + taskId: string +} + +export function KanbanCardDrawer({ onClose, taskId }: KanbanCardDrawerProps) { + const { t } = useI18n() + const k = t.kanban + const board = useStore($kanbanBoard) + const activeBoard = useStore($kanbanActiveBoard) + + const [detail, setDetail] = useState(null) + const [loadError, setLoadError] = useState(null) + const [assignees, setAssignees] = useState([]) + const [saving, setSaving] = useState(false) + const [commentDraft, setCommentDraft] = useState('') + const [sendingComment, setSendingComment] = useState(false) + + const reload = useCallback(async () => { + try { + setDetail(await getKanbanTask(taskId, $kanbanActiveBoard.get())) + setLoadError(null) + } catch (error) { + setLoadError(error instanceof Error ? error.message : String(error)) + } + }, [taskId]) + + useEffect(() => { + void reload() + // Re-pull the detail whenever the board cursor moves — comments/events/runs + // append task_events rows, so the cursor is a reliable staleness signal. + }, [reload, board?.latest_event_id]) + + useEffect(() => { + let cancelled = false + + getKanbanAssignees(activeBoard) + .then(list => { + if (!cancelled) { + setAssignees(list.map(entry => entry.name)) + } + }) + .catch(() => undefined) + + return () => { + cancelled = true + } + }, [activeBoard]) + + const patchTask = useCallback( + async (updates: KanbanUpdateTaskPayload) => { + setSaving(true) + + try { + const response = await updateKanbanTask(taskId, updates, $kanbanActiveBoard.get()) + + // Server-authoritative: render whatever came back (a status PATCH may + // land on a different column than requested under local board rules). + if (response.task) { + applyKanbanTaskPatch(response.task) + + if (updates.status && response.task.status !== updates.status) { + notify({ + kind: 'info', + message: k.movedByRules(k.columnLabels[response.task.status] ?? response.task.status) + }) + } + } + + await reload() + void refreshKanbanBoard() + } catch (error) { + notifyError(error, k.updateFailed) + // Refetch so the editors snap back to the server's state. + await reload() + } finally { + setSaving(false) + } + }, + [k, reload, taskId] + ) + + async function submitComment() { + const body = commentDraft.trim() + + if (!body || sendingComment) { + return + } + + setSendingComment(true) + + try { + await addKanbanComment(taskId, body, $kanbanActiveBoard.get()) + setCommentDraft('') + await reload() + void refreshKanbanBoard() + } catch (error) { + notifyError(error, k.commentFailed) + } finally { + setSendingComment(false) + } + } + + const task = detail?.task + const statusOptions = task ? [task.status, ...allowedDropColumns(task.status)] : [] + + const assigneeOptions = + task?.assignee && !assignees.includes(task.assignee) ? [task.assignee, ...assignees] : assignees + + return ( +