feat(desktop): Kanban — dashboard-parity board plugin on the SDK

The founding opt-in plugin (defaultEnabled: false): /kanban board + drawer,
live task_events via ctx.socket, ⌘-click bulk ops, auto-nudge dispatch,
collapsible lanes, board switcher, and prose activity — all pure SDK-consumer
work against plugins/kanban/dashboard/plugin_api.py. Backend: /boards totals
count live cards only.
This commit is contained in:
Brooklyn Nicholson 2026-07-15 14:10:52 -04:00
parent 81aacdef4d
commit 79e7adae2d
10 changed files with 3325 additions and 1 deletions

View file

@ -0,0 +1,209 @@
/**
* Kanban data layer. Everything goes through `ctx.rest` the plugin's own
* `/api/plugins/kanban/*` FastAPI router (`plugins/kanban/dashboard/plugin_api.py`),
* reused as-is via the desktop's namespace-scoped REST door. No new backend.
*
* Fetching, caching, polling, dedupe, and invalidation are React Query's job
* (the app's standard, via the SDK). This module owns the query keys, the REST
* calls, and the selected-board atom every call passes `?board=<slug>` so the
* desktop's selection never flips the server-wide current-board pointer.
*/
import { atom, type PluginRestOptions, type PluginStorage, queryClient } from '@hermes/plugin-sdk'
import type {
BoardsResponse,
KanbanBoard,
KanbanProfile,
KanbanTask,
KanbanTaskDetail,
OrchestrationSettings,
WorkerLog
} from './types'
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
type Socket = (path: string, onMessage: (data: unknown) => void) => () => void
let rest: null | Rest = null
/** Selected board slug ('' = the server's current board). Persisted. */
export const $boardSlug = atom<string>('')
/** Whether the "how this board works" intro was dismissed. Persisted. */
export const $introDismissed = atom<boolean>(false)
/** Sub-group the Running lane by assignee (the dashboard's "lanes by
* profile"). Persisted. */
export const $lanesByProfile = atom<boolean>(false)
/** Per-lane collapse OVERRIDES (true=collapsed, false=expanded). Absence means
* auto: empty lanes collapse to a rail, occupied lanes expand. Persisted. */
export const $collapsedLanes = atom<Record<string, boolean>>({})
const BOARD_SLUG_KEY = 'boardSlug'
const INTRO_KEY = 'introDismissed'
const LANES_KEY = 'lanesByProfile'
const COLLAPSED_KEY = 'collapsedLanes'
/** One live `task_events` frame precise cache invalidation: the board, plus
* each touched task's detail. The polls (8s board / 4s drawer) stay as the
* fallback the socket just makes the board feel instant. */
function onEventsFrame(slug: string, data: unknown): void {
const events = (data as { events?: Array<{ task_id?: string }> })?.events
if (!events?.length) {
return
}
void queryClient.invalidateQueries({ queryKey: ['kanban', 'board'] })
// Any event can change a board's card count — keep the switcher badge honest.
void queryClient.invalidateQueries({ queryKey: BOARDS_KEY })
for (const taskId of new Set(events.map(event => event.task_id).filter(Boolean))) {
void queryClient.invalidateQueries({ queryKey: taskKey(slug, taskId!) })
}
}
/** Bind the plugin's doors once, at register time. The events socket is pinned
* to a board at handshake, so a board switch closes + reopens it. */
export function bindApi(r: Rest, storage: PluginStorage, socket: Socket): void {
rest = r
$boardSlug.set(storage.get(BOARD_SLUG_KEY, ''))
$boardSlug.listen(slug => storage.set(BOARD_SLUG_KEY, slug))
$introDismissed.set(storage.get(INTRO_KEY, false))
$introDismissed.listen(dismissed => storage.set(INTRO_KEY, dismissed))
$lanesByProfile.set(storage.get(LANES_KEY, false))
$lanesByProfile.listen(on => storage.set(LANES_KEY, on))
$collapsedLanes.set(storage.get(COLLAPSED_KEY, {}))
$collapsedLanes.listen(map => storage.set(COLLAPSED_KEY, map))
let close: (() => void) | null = null
const open = (slug: string) => {
close?.()
close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data))
}
open($boardSlug.get())
$boardSlug.listen(open)
}
function call<T>(path: string, opts?: PluginRestOptions): Promise<T> {
return rest ? rest<T>(path, opts) : Promise.reject(new Error('kanban api not ready'))
}
/** Append the selected board (and other params) to a path. */
function withBoard(path: string, params: Record<string, string> = {}): string {
const search = new URLSearchParams(params)
const slug = $boardSlug.get()
if (slug) {
search.set('board', slug)
}
const qs = search.toString()
return qs ? `${path}?${qs}` : path
}
// ── query keys (all board-scoped so switching boards is a clean cache miss) ──
export const boardKey = (slug: string, archived: boolean) => ['kanban', 'board', slug, archived] as const
export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id] as const
export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const
export const BOARDS_KEY = ['kanban', 'boards'] as const
export const PROFILES_KEY = ['kanban', 'profiles'] as const
export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const
// ── reads ─────────────────────────────────────────────────────────────────────
export const fetchBoard = (archived: boolean) =>
call<KanbanBoard>(withBoard('/board', archived ? { include_archived: 'true' } : {}))
export const fetchTask = (id: string) => call<KanbanTaskDetail>(withBoard(`/tasks/${id}`))
/** Worker stdout/stderr tail (last 16 KiB — plenty for the drawer). */
export const fetchLog = (id: string) => call<WorkerLog>(withBoard(`/tasks/${id}/log`, { tail: '16384' }))
export const fetchBoards = () => call<BoardsResponse>('/boards')
export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles')
export const fetchOrchestration = () => call<OrchestrationSettings>('/orchestration')
// ── writes ────────────────────────────────────────────────────────────────────
// Every board edit nudges the dispatcher (debounced, fire-and-forget) so the
// change takes effect NOW instead of on the next 60s tick — create a ready
// task and the worker spawns immediately, no manual "nudge" ritual. The tick
// is lock-guarded and ~1ms when there's nothing to do, so over-nudging is
// free; failures are non-events (the periodic tick still exists).
let nudgeTimer: null | ReturnType<typeof setTimeout> = null
function autoNudge(): void {
if (nudgeTimer != null) {
clearTimeout(nudgeTimer)
}
nudgeTimer = setTimeout(() => {
nudgeTimer = null
nudgeDispatcher().catch(() => undefined)
}, 400)
}
/** Resolve the write, then kick the dispatcher. Rejections pass through. */
function nudged<T>(write: Promise<T>): Promise<T> {
return write.then(value => {
autoNudge()
return value
})
}
export const patchTask = (id: string, patch: Record<string, unknown>) =>
nudged(call(withBoard(`/tasks/${id}`), { method: 'PATCH', body: patch }))
export const createTask = (body: Record<string, unknown>) =>
nudged(call<{ task: KanbanTask | null; warning?: string }>(withBoard('/tasks'), { method: 'POST', body }))
// Deleting can unblock dependants (a gone parent no longer gates), so it
// nudges too.
export const deleteTask = (id: string) => nudged(call(withBoard(`/tasks/${id}`), { method: 'DELETE' }))
/** One patch, many ids independent per-id application; returns per-id
* outcomes so the UI can toast partial failures. */
export const bulkTasks = (ids: string[], patch: Record<string, unknown>) =>
nudged(
call<{ results: Array<{ id: string; ok: boolean; error?: string }> }>(withBoard('/tasks/bulk'), {
method: 'POST',
body: { ids, ...patch }
})
)
export const addComment = (id: string, body: string) =>
call(withBoard(`/tasks/${id}/comments`), { method: 'POST', body: { author: 'desktop', body } })
export const reassignTask = (id: string, profile: string) =>
nudged(call(withBoard(`/tasks/${id}/reassign`), { method: 'POST', body: { profile, reclaim_first: true } }))
export const reclaimTask = (id: string) => nudged(call(withBoard(`/tasks/${id}/reclaim`), { method: 'POST', body: {} }))
export const uploadAttachment = (id: string, upload: { filename: string; contentType?: string; bytes: ArrayBuffer }) =>
call(withBoard(`/tasks/${id}/attachments`), { method: 'POST', upload })
export const createBoard = (slug: string, name: string) =>
call<{ board: { slug: string } }>('/boards', { method: 'POST', body: { slug, name } })
export const nudgeDispatcher = () => call<{ spawned?: unknown[] }>(withBoard('/dispatch'), { method: 'POST', body: {} })
export const saveOrchestration = (patch: Record<string, unknown>) =>
call<OrchestrationSettings>('/orchestration', { method: 'PUT', body: patch })
export const saveProfileDescription = (name: string, description: string) =>
call(`/profiles/${encodeURIComponent(name)}`, { method: 'PATCH', body: { description } })
export const autoDescribeProfile = (name: string) =>
call<{ ok: boolean; reason?: null | string; description?: null | string }>(
`/profiles/${encodeURIComponent(name)}/describe-auto`,
{ method: 'POST', body: { overwrite: true } }
)

View file

@ -0,0 +1,136 @@
/**
* Titlebar board switcher the board page projects this into `titleBar.center`
* (where chat shows the session-title dropdown) via `<Contribute>`, so it
* exists exactly while the page is mounted no route sniffing. Same chrome as
* the session title: quiet label + chevron, menu on click.
*/
import {
Button,
Codicon,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
host,
Input,
useMutation,
useQuery,
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards } from './api'
import { errText } from './ui'
function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean }) {
const qc = useQueryClient()
const [name, setName] = useState('')
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
useEffect(() => {
if (open) {
setName('')
}
}, [open])
const create = useMutation({
mutationFn: () => createBoard(slug, name.trim()),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: result => {
$boardSlug.set(result.board.slug)
void qc.invalidateQueries({ queryKey: BOARDS_KEY })
onClose()
}
})
return (
<Dialog onOpenChange={o => !o && onClose()} open={open}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>New board</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Input
autoFocus
onChange={event => setName(event.target.value)}
onKeyDown={event => event.key === 'Enter' && slug && create.mutate()}
placeholder="Board name"
value={name}
/>
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {slug}</span>}
</div>
<DialogFooter>
<Button onClick={onClose} variant="text">
Cancel
</Button>
<Button disabled={!slug || create.isPending} onClick={() => create.mutate()}>
Create board
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function BoardSwitcher() {
const slug = useValue($boardSlug)
const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 })
const [adding, setAdding] = useState(false)
if (!boards) {
return null
}
const currentSlug = slug || boards.current
const current = boards.boards.find(meta => meta.slug === currentSlug)
const label = current?.name || current?.slug || 'Board'
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-7 max-w-56 gap-1.5 px-2" size="sm" variant="ghost">
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{label}</span>
{typeof current?.total === 'number' && (
<span className="text-[0.6875rem] tabular-nums text-(--ui-text-quaternary)">{current.total}</span>
)}
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
{boards.boards.map(meta => (
<DropdownMenuItem
key={meta.slug}
onSelect={() => $boardSlug.set(meta.slug === boards.current ? '' : meta.slug)}
>
{meta.name || meta.slug}
{typeof meta.total === 'number' && (
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">{meta.total}</span>
)}
{meta.slug === currentSlug && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setAdding(true)}>
<Codicon name="add" size="0.8rem" />
New board
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewBoardDialog onClose={() => setAdding(false)} open={adding} />
</>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,808 @@
/**
* Task drawer the desktop port of the dashboard's task detail, flat-styled:
* status menu + meta table, DIAGNOSTICS (the "why is this stuck" panel, with
* reassign recovery), description (editable), result/summary, dependencies,
* comments (+composer), activity, run history, and the worker log tail.
*/
import {
Badge,
Button,
cn,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ErrorState,
host,
Loader,
LogView,
Textarea,
useMutation,
useQuery,
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import {
$boardSlug,
addComment,
deleteTask,
fetchLog,
fetchProfiles,
fetchTask,
logKey,
patchTask,
PROFILES_KEY,
reassignTask,
reclaimTask,
taskKey,
uploadAttachment
} from './api'
import {
columnMeta,
type Diagnostic,
type DiagnosticAction,
type KanbanAttachment,
type KanbanEvent,
type KanbanTaskDetail,
SEVERITY_TONE
} from './types'
import {
ago,
Avatar,
Callout,
duration,
errText,
isLockedTarget,
LOCKED_COLUMNS,
ScrollFade,
Section,
shortId,
StatusMenu,
useDefaultAssignee
} from './ui'
/**
* Turn a task_events row into an operator-readable line. The backend logs
* machine payloads ("status" + {"status":"ready"}); rendering the raw kind
* made the feed useless ("status · 2 sec. ago" after a drag). Known kinds get
* prose with the payload folded in; unknown kinds fall back to kind + compact
* key=value detail so new backend events still say something.
*/
function eventText(event: KanbanEvent): { detail?: string; label: string } {
let p: Record<string, unknown> = {}
if (typeof event.payload === 'string' && event.payload) {
try {
p = JSON.parse(event.payload) as Record<string, unknown>
} catch {
return { label: event.kind.replace(/_/g, ' '), detail: event.payload }
}
} else if (event.payload && typeof event.payload === 'object') {
p = event.payload as Record<string, unknown>
}
const str = (key: string): null | string => {
const value = p[key]
return typeof value === 'string' && value ? value : null
}
const col = (key: string) => {
const value = str(key)
return value ? columnMeta(value).label : null
}
switch (event.kind) {
case 'created': {
const where = col('status')
const assignee = str('assignee')
return {
label: `created${where ? ` in ${where}` : ''}${assignee ? ` · assigned to ${assignee}` : ''}`
}
}
case 'status': {
const reason = str('reason')
return {
label: `moved to ${col('status') ?? '?'}`,
detail: reason === 'parent_reopened' ? `parent ${str('parent') ?? ''} reopened` : (reason ?? undefined)
}
}
case 'assigned': {
const assignee = str('assignee')
return { label: assignee ? `assigned to ${assignee}` : 'unassigned' }
}
case 'commented':
return { label: `comment by ${str('author') ?? 'someone'}` }
case 'claimed':
return { label: str('source_status') === 'review' ? 'claimed by a review agent' : 'claimed by a worker' }
case 'spawned':
return { label: 'worker started', detail: p.pid != null ? `pid ${p.pid}` : undefined }
case 'completed':
return { label: 'completed' }
case 'blocked':
return { label: 'blocked — needs human input', detail: str('reason') ?? undefined }
case 'unblocked':
return { label: `unblocked${col('status') ? `${col('status')}` : ' → Ready'}` }
case 'reclaimed':
return { label: 'reclaimed — returned to the queue', detail: str('reason') ?? undefined }
case 'specified':
return { label: 'spec written by the triage agent' }
case 'promoted':
return { label: 'dependencies done — promoted to Ready' }
case 'scheduled':
return { label: 'scheduled for later', detail: str('reason') ?? undefined }
case 'archived':
return { label: 'archived' }
case 'reprioritized':
return { label: `priority set to ${p.priority ?? '?'}` }
default: {
const detail = Object.entries(p)
.filter(([, value]) => value != null && typeof value !== 'object')
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ')
return { label: event.kind.replace(/_/g, ' '), detail: detail || undefined }
}
}
}
function MetaRow({ children, label }: { children: ReactNode; label: string }) {
return (
<>
<span className="text-(--ui-text-quaternary)">{label}</span>
<span className="min-w-0 truncate text-(--ui-text-secondary)">{children}</span>
</>
)
}
/** The dashboard's diagnostics panel: severity-toned, plain-English, with the
* backend's structured recovery actions as buttons. `reassign` is skipped
* the Assignee control in the meta table IS that action, inline. */
function Diagnostics({ items, onReclaim }: { items: Diagnostic[]; onReclaim: () => void }) {
const act = (action: DiagnosticAction) => {
if (action.kind === 'reclaim') {
onReclaim()
} else if (action.kind === 'cli_hint') {
void navigator.clipboard.writeText(String(action.payload?.command ?? action.label))
host.notify({ kind: 'info', message: 'Command copied' })
}
}
return (
<div className="flex flex-col gap-2">
{items.map(diag => {
const tone = SEVERITY_TONE[diag.severity]
const actions = diag.actions.filter(action => action.kind === 'reclaim' || action.kind === 'cli_hint')
return (
<Callout
key={`${diag.kind}-${diag.last_seen_at}`}
title={`${diag.title}${diag.count > 1 ? ` ×${diag.count}` : ''}`}
tone={tone}
>
<p className="whitespace-pre-wrap text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">
{diag.detail}
</p>
{actions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{actions.map(action => (
<Button
key={`${action.kind}-${action.label}`}
onClick={() => act(action)}
size="xs"
variant={action.suggested ? 'secondary' : 'outline'}
>
{action.kind === 'cli_hint' && <Codicon name="copy" size="0.7rem" />}
{action.label}
</Button>
))}
</div>
)}
</Callout>
)
})}
</div>
)
}
/** Jira-style inline assignee editor: the meta row IS the control click the
* assignee to reassign (reclaims a running worker first, resets the failure
* streak the explicit human recovery action). */
function AssigneeMenu({
current,
onReassign
}: {
current: null | string | undefined
onReassign: (p: string) => void
}) {
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="-mx-1 inline-flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-(--chrome-action-hover)"
type="button"
>
{current ? (
<>
<Avatar name={current} size="0.875rem" />
<span className="truncate">{current}</span>
</>
) : (
<span className="text-(--ui-text-quaternary)">unassigned</span>
)}
<Codicon className="shrink-0 text-(--ui-text-quaternary)" name="chevron-down" size="0.65rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(roster?.profiles ?? []).map(profile => (
<DropdownMenuItem key={profile.name} onSelect={() => onReassign(profile.name)}>
<Avatar name={profile.name} size="0.875rem" />
{profile.name}
{profile.name === current && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// Mirrors the review pane's commit-message field: one row tall to start
// (button-height), CSS field-sizing grows it with content, button hugs the
// bottom edge as it grows.
function CommentComposer({ onSubmit, pending }: { onSubmit: (body: string) => void; pending: boolean }) {
const [body, setBody] = useState('')
const submit = () => {
const trimmed = body.trim()
if (trimmed && !pending) {
onSubmit(trimmed)
setBody('')
}
}
return (
<div className="relative">
<Textarea
className="field-sizing-content max-h-40 min-h-0 resize-none pr-[5rem]"
onChange={event => setBody(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
placeholder="Add a comment…"
rows={1}
size="sm"
value={body}
/>
<Button
className="absolute top-1 right-1"
disabled={!body.trim() || pending}
onClick={submit}
size="xs"
variant="secondary"
>
Comment
</Button>
</div>
)
}
function DescriptionSection({ body, onSave }: { body: null | string | undefined; onSave: (body: string) => void }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
return (
<Section
action={
<Button
aria-label={editing ? 'Cancel edit' : 'Edit description'}
onClick={() => {
setDraft(body ?? '')
setEditing(!editing)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={editing ? 'close' : 'edit'} size="0.75rem" />
</Button>
}
label="Description"
>
{editing ? (
<div className="flex flex-col gap-1.5">
<Textarea
className="min-h-24 text-[0.75rem]"
onChange={event => setDraft(event.target.value)}
value={draft}
/>
<Button
className="self-end"
onClick={() => {
onSave(draft)
setEditing(false)
}}
size="xs"
variant="secondary"
>
Save
</Button>
</div>
) : body ? (
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{body}</p>
) : (
<p className="text-[0.8125rem] text-(--ui-text-quaternary)">No description yet.</p>
)}
</Section>
)
}
// `latest_summary` is just the newest non-null run summary. A reclaim writes an
// administrative note into that slot; hide those (Runs still shows them).
const isAdminSummary = (summary: string) => /^status changed to \w+ \(dashboard\/direct\)$/.test(summary)
function AttachmentsSection({
attachments,
onUpload,
pending
}: {
attachments: KanbanAttachment[]
onUpload: (file: File) => void
pending: boolean
}) {
const fileRef = useRef<HTMLInputElement>(null)
return (
<Section
action={
<>
<input
hidden
onChange={event => {
const file = event.target.files?.[0]
if (file) {
onUpload(file)
}
event.target.value = ''
}}
ref={fileRef}
type="file"
/>
<Button
aria-label="Upload attachment"
disabled={pending}
onClick={() => fileRef.current?.click()}
size="icon-xs"
variant="ghost"
>
<Codicon name={pending ? 'sync' : 'cloud-upload'} size="0.8rem" spinning={pending} />
</Button>
</>
}
label={`Attachments · ${attachments.length}`}
>
{attachments.length > 0 ? (
<ul className="flex flex-col gap-1">
{attachments.map(attachment => (
<li className="flex items-center gap-1.5 text-[0.75rem] text-(--ui-text-tertiary)" key={attachment.id}>
<Codicon name="file" size="0.75rem" />
{attachment.filename}
</li>
))}
</ul>
) : (
<p className="text-[0.75rem] text-(--ui-text-quaternary)">No attachments yet.</p>
)}
</Section>
)
}
export function TaskDrawer({
columns,
id,
onClose,
onOpen
}: {
columns: string[]
id: null | string
onClose: () => void
onOpen: (id: string) => void
}) {
const qc = useQueryClient()
const slug = useValue($boardSlug)
// Socket-invalidated (bindApi); the interval is only the socketless heartbeat.
const { data: detail, error } = useQuery({
enabled: !!id,
queryFn: () => fetchTask(id!),
queryKey: taskKey(slug, id ?? ''),
refetchInterval: 30_000
})
const task = detail?.task
const running = task?.status === 'running'
const defaultAssignee = useDefaultAssignee()
const { data: log } = useQuery({
enabled: !!id,
queryFn: () => fetchLog(id!),
queryKey: logKey(slug, id ?? ''),
refetchInterval: running ? 3_000 : 15_000
})
// Esc closes the drawer even though it isn't modal (no backdrop to click off).
useEffect(() => {
if (!id) {
return
}
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [id, onClose])
const invalidate = () => {
void qc.invalidateQueries({ queryKey: taskKey(slug, id!) })
void qc.invalidateQueries({ queryKey: ['kanban', 'board', slug] })
}
// Optimistic status change against the task cache; rolls back + toasts on a
// rejected transition (the backend enforces the workflow).
const moveMut = useMutation({
mutationFn: (status: string) => patchTask(id!, { status }),
onMutate: async status => {
await qc.cancelQueries({ queryKey: taskKey(slug, id!) })
const previous = qc.getQueryData<KanbanTaskDetail>(taskKey(slug, id!))
if (previous) {
qc.setQueryData(taskKey(slug, id!), { ...previous, task: { ...previous.task, status } })
}
return { previous }
},
onError: (err, _status, context) => {
if (context?.previous) {
qc.setQueryData(taskKey(slug, id!), context.previous)
}
host.notify({ kind: 'error', message: errText(err) })
},
onSettled: invalidate
})
const mutate = (fn: () => Promise<unknown>, onDone?: () => void) => () =>
fn().then(
() => {
invalidate()
onDone?.()
},
(err: unknown) => host.notify({ kind: 'error', message: errText(err) })
)
const commentMut = useMutation({
mutationFn: (body: string) => addComment(id!, body),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
const uploadMut = useMutation({
mutationFn: async (file: File) =>
uploadAttachment(id!, {
bytes: await file.arrayBuffer(),
contentType: file.type || undefined,
filename: file.name
}),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
if (!id) {
return null
}
const errorMessage = error ? errText(error) : null
const move = (status: string) => {
if (!task || status === task.status) {
return
}
if (isLockedTarget(status)) {
host.notify({ kind: 'info', message: LOCKED_COLUMNS[status] })
return
}
moveMut.mutate(status)
}
return (
<div className="absolute inset-y-0 right-0 z-20 flex w-[26rem] flex-col border-l border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) duration-150 ease-out animate-in fade-in slide-in-from-right-4">
<header className="flex flex-col gap-2 px-4 pt-3.5 pb-3">
<div className="flex items-center gap-2">
{task ? (
<StatusMenu columns={columns} onMove={move} status={task.status} />
) : (
<span className="font-mono text-sm text-(--ui-text-tertiary)">{shortId(id)}</span>
)}
{task && (
<span className="font-mono text-[0.625rem] text-(--ui-text-quaternary)" data-selectable-text="true">
{shortId(task.id)}
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
{task && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label="Task actions"
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
type="button"
>
<Codicon name="ellipsis" size="0.9rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.id)
host.notify({ kind: 'info', message: `Copied ${task.id}` })
}}
>
<Codicon name="copy" size="0.85rem" />
Copy task id
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.title || task.id)
host.notify({ kind: 'info', message: 'Copied title' })
}}
>
<Codicon name="copy" size="0.85rem" />
Copy title
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
<Codicon name="archive" size="0.85rem" />
Archive task
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onSelect={mutate(() => deleteTask(task.id), onClose)}>
<Codicon name="trash" size="0.85rem" />
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
aria-label="Close"
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClose}
type="button"
>
<Codicon name="close" size="0.9rem" />
</button>
</div>
</div>
{task && (
<h2 className="text-sm leading-snug font-semibold text-foreground" data-selectable-text="true">
{task.title || task.id}
</h2>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4" data-selectable-text="true">
{errorMessage ? (
<ErrorState title={errorMessage} />
) : !detail || !task ? (
<div className="grid h-32 place-items-center">
<Loader type="lemniscate-bloom" />
</div>
) : (
<div className="flex flex-col gap-4 text-sm">
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-[0.71rem]">
<MetaRow label="Assignee">
<AssigneeMenu
current={task.assignee}
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
/>
</MetaRow>
{typeof task.priority === 'number' && <MetaRow label="Priority">{task.priority}</MetaRow>}
{task.tenant && <MetaRow label="Tenant">{task.tenant}</MetaRow>}
{task.workspace_path && (
<MetaRow label="Workspace">
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
{task.workspace_path}
</MetaRow>
)}
{task.created_by && <MetaRow label="Created by">{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label="Created">{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label="Worker pid">{task.worker_pid}</MetaRow> : null}
</div>
{task.status === 'ready' && !task.assignee && !defaultAssignee && (
<Callout title="Ready, but unassigned — this card will never run." tone={SEVERITY_TONE.warning}>
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">
The dispatcher only claims Ready cards that have an assignee. Pick a profile in the Assignee field
above (or set a default assignee in the orchestration settings) and it runs within a minute.
</p>
</Callout>
)}
{task.diagnostics && task.diagnostics.length > 0 && (
<Section label={`Diagnostics · ${task.diagnostics.length}`}>
<Diagnostics items={task.diagnostics} onReclaim={() => void mutate(() => reclaimTask(task.id))()} />
</Section>
)}
<DescriptionSection body={task.body} onSave={body => void mutate(() => patchTask(task.id, { body }))()} />
{task.result && (
<Section label="Result">
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>
</Section>
)}
{task.latest_summary && !isAdminSummary(task.latest_summary) && (
<Section label="Latest summary">
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.latest_summary}</p>
</Section>
)}
{(detail.links.parents.length > 0 || detail.links.children.length > 0) && (
<Section label="Dependencies">
{(['parents', 'children'] as const).map(side =>
detail.links[side].length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" key={side}>
<span className="text-[0.6875rem] text-(--ui-text-quaternary)">
{side === 'parents' ? 'Blocked by' : 'Blocks'}
</span>
{detail.links[side].map(linked => (
<button
className="rounded bg-(--ui-bg-quaternary) px-1.5 py-0.5 font-mono text-[0.625rem] text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
key={linked}
onClick={() => onOpen(linked)}
type="button"
>
{shortId(linked)}
</button>
))}
</div>
) : null
)}
</Section>
)}
<Section label={`Comments · ${detail.comments.length}`}>
{detail.comments.length > 0 && (
<ul className="flex flex-col gap-2">
{detail.comments.map(comment => (
<li className="text-[0.75rem]" key={comment.id}>
<span className="font-medium text-(--ui-text-secondary)">{comment.author}</span>
<span className="ml-2 text-[0.625rem] text-(--ui-text-quaternary)">
{ago(comment.created_at)}
</span>
<p className="whitespace-pre-wrap text-(--ui-text-tertiary)">{comment.body}</p>
</li>
))}
</ul>
)}
<CommentComposer onSubmit={body => commentMut.mutate(body)} pending={commentMut.isPending} />
</Section>
{detail.events.length > 0 && (
<Section label={`Activity · ${detail.events.length}`}>
<ScrollFade deps={detail.events.length} max="7rem">
<ul className="flex flex-col gap-1">
{detail.events.map(event => {
const { detail: extra, label } = eventText(event)
return (
<li className="flex items-baseline gap-2 text-[0.6875rem]" key={event.id}>
<span className="shrink-0 text-(--ui-text-secondary)">{label}</span>
{extra && (
<span
className="min-w-0 truncate text-[0.625rem] text-(--ui-text-quaternary)"
title={extra}
>
{extra}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">{ago(event.created_at)}</span>
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{detail.runs.length > 0 && (
<Section label={`Runs · ${detail.runs.length}`}>
<ScrollFade max="11rem">
<ul className="flex flex-col gap-1.5">
{detail.runs.map(run => {
const failed = ['crashed', 'failed', 'timed_out', 'gave_up'].includes(run.outcome ?? run.status)
return (
<li className="flex flex-col gap-0.5 text-[0.71rem]" key={run.id}>
<div className="flex items-center gap-2">
<Badge size="xs" variant={failed ? 'destructive' : 'muted'}>
{run.outcome ?? run.status}
</Badge>
{run.profile && <span className="text-(--ui-text-tertiary)">{run.profile}</span>}
{duration(run.started_at, run.ended_at) && (
<span className="text-(--ui-text-quaternary)">
{duration(run.started_at, run.ended_at)}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">
{ago(run.ended_at ?? run.started_at)}
</span>
</div>
{(run.error || run.summary) && (
<p
className={cn(
'line-clamp-2 whitespace-pre-wrap',
run.error ? 'text-destructive' : 'text-(--ui-text-quaternary)'
)}
>
{run.error ?? run.summary}
</p>
)}
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{log?.exists && log.content && (
<Section label={`Worker log${log.truncated ? ' · tail' : ''}`}>
<ScrollFade deps={log.content.length} max="12rem">
<LogView className="border-0 px-0">{log.content}</LogView>
</ScrollFade>
</Section>
)}
<AttachmentsSection
attachments={detail.attachments}
onUpload={file => uploadMut.mutate(file)}
pending={uploadMut.isPending}
/>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,46 @@
/* Machine-activity arc a highlight that travels the card's border while an
agent is ACTUALLY working the card (amber-slow when the heartbeat is gone).
Queued/attached cards do NOT animate that's the footer's named-agent chip.
The tone rides --kanban-tone, set inline from the column meta. Painted as an
overlay ring (mask keeps only the border band) so the card stays flat. */
@property --kanban-arc-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.kanban-arc {
pointer-events: none;
position: absolute;
inset: -1px; /* sit on the card's own border line */
border-radius: inherit;
padding: 1.5px; /* arc thickness */
background: conic-gradient(
from var(--kanban-arc-angle),
transparent 0deg,
var(--kanban-tone, var(--ui-stroke-primary)) 55deg,
transparent 110deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: kanban-arc-spin 2.2s linear infinite;
}
/* Running but no heartbeat: amber crawl — still claimed, health unknown. */
.kanban-arc--stale {
--kanban-tone: #fbbf24;
animation-duration: 6s;
}
@keyframes kanban-arc-spin {
to {
--kanban-arc-angle: 360deg;
}
}

View file

@ -0,0 +1,179 @@
/**
* Orchestration settings the dashboard's dispatcher-knobs panel, flat-styled:
* orchestrator profile, default assignee, auto-decompose, and the profile
* descriptions the decomposer routes by (save / auto-generate per profile).
*/
import {
Button,
Codicon,
host,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
useMutation,
useQuery,
useQueryClient
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import {
autoDescribeProfile,
fetchOrchestration,
fetchProfiles,
ORCHESTRATION_KEY,
PROFILES_KEY,
saveOrchestration,
saveProfileDescription
} from './api'
import type { KanbanProfile } from './types'
import { errText, FIELD_LABEL } from './ui'
const DEFAULT_SENTINEL = '__default__'
function ProfilePicker({
label,
onSave,
profiles,
value
}: {
label: string
onSave: (name: string) => void
profiles: KanbanProfile[]
value: string
}) {
return (
<label className="flex min-w-0 flex-col gap-1">
<span className={FIELD_LABEL}>{label}</span>
<Select onValueChange={name => onSave(name === DEFAULT_SENTINEL ? '' : name)} value={value || DEFAULT_SENTINEL}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_SENTINEL}>(default)</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
const qc = useQueryClient()
const [draft, setDraft] = useState(profile.description)
const invalidate = () => void qc.invalidateQueries({ queryKey: PROFILES_KEY })
const save = useMutation({
mutationFn: () => saveProfileDescription(profile.name, draft.trim()),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
const auto = useMutation({
mutationFn: () => autoDescribeProfile(profile.name),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: result => {
if (result.ok) {
setDraft(result.description ?? '')
invalidate()
} else {
host.notify({ kind: 'warning', message: result.reason || 'Auto-describe failed' })
}
}
})
return (
<div className="flex items-center gap-2">
<span className="w-24 shrink-0 truncate text-[0.75rem] font-medium text-(--ui-text-secondary)">
{profile.name}
{profile.is_default && <span className="ml-1 text-[0.625rem] text-(--ui-text-quaternary)">(default)</span>}
</span>
<Input
className="h-7 flex-1 text-[0.71rem]"
onChange={event => setDraft(event.target.value)}
placeholder="What is this profile good at?"
value={draft}
/>
<Button
disabled={save.isPending || draft.trim() === profile.description}
onClick={() => save.mutate()}
size="xs"
variant="outline"
>
Save
</Button>
{/* Overlay the spinner so the button keeps its "Auto" width the aux
model can take a few seconds and a text swap would jump the row. */}
<Button className="relative" disabled={auto.isPending} onClick={() => auto.mutate()} size="xs" variant="ghost">
<span className={auto.isPending ? 'invisible' : ''}>Auto</span>
{auto.isPending && (
<span className="absolute inset-0 grid place-items-center">
<Codicon className="animate-spin [animation-duration:1.2s]" name="loading" size="0.75rem" />
</span>
)}
</Button>
</div>
)
}
export function OrchestrationPanel() {
const qc = useQueryClient()
const { data: settings } = useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration })
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
const save = useMutation({
mutationFn: (patch: Record<string, unknown>) => saveOrchestration(patch),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ORCHESTRATION_KEY })
})
if (!settings || !roster) {
return null
}
return (
<div className="flex flex-col gap-4 border-t border-(--ui-stroke-tertiary) px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
<ProfilePicker
label="Orchestrator profile"
onSave={name => save.mutate({ orchestrator_profile: name })}
profiles={roster.profiles}
value={settings.orchestrator_profile}
/>
<ProfilePicker
label="Default assignee"
onSave={name => save.mutate({ default_assignee: name })}
profiles={roster.profiles}
value={settings.default_assignee}
/>
<label className="flex cursor-pointer items-center gap-2 pb-1.5 text-[0.75rem] text-(--ui-text-secondary)">
<Switch
aria-label="Auto-decompose triage tasks"
checked={settings.auto_decompose}
onCheckedChange={checked => save.mutate({ auto_decompose: checked })}
size="xs"
/>
Auto-decompose triage tasks
</label>
</div>
<div className="flex flex-col gap-1.5">
<span className={FIELD_LABEL}>Profile descriptions</span>
<p className="text-[0.6875rem] text-(--ui-text-quaternary)">
Descriptions guide the decomposer's routing. Auto-generate with the auxiliary model, or write your own.
</p>
{roster.profiles.map(profile => (
<ProfileDescriptionRow key={`${profile.name}:${profile.description}`} profile={profile} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,113 @@
/**
* Kanban the founding plugin use case, now pure SDK-consumer work: a
* first-class `/kanban` board page + sidebar nav row + a live statusbar count,
* all reusing the existing `plugins/kanban/dashboard/plugin_api.py` REST router
* through `ctx.rest` (namespace-scoped to `/api/plugins/kanban`). No new
* backend, no core edits.
*
* Ships OFF by default (`defaultEnabled: false`): it inventories in
* Settings Plugins and registers nothing until the user flips the switch.
*/
import './kanban.css'
import {
cn,
Codicon,
type HermesPlugin,
host,
PALETTE_AREA,
type PaletteContribution,
type RouteContribution,
ROUTES_AREA,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
STATUSBAR_AREAS,
useQuery,
useValue
} from '@hermes/plugin-sdk'
import { $boardSlug, bindApi, boardKey, fetchBoard } from './api'
import { KanbanBoardPage } from './board'
// Live "N running / ready" pill — one glance at fleet activity from anywhere,
// clicks through to the board. Shares the board query (one cache, one poll with
// the page); hidden when nothing is in flight (or unloaded).
function KanbanCount() {
const slug = useValue($boardSlug)
// Socket-invalidated like the page (same cache); slow socketless heartbeat.
const { data: board } = useQuery({
queryFn: () => fetchBoard(false),
queryKey: boardKey(slug, false),
refetchInterval: 60_000
})
if (!board) {
return null
}
const count = (name: string) => board.columns.find(col => col.name === name)?.tasks.length ?? 0
const active = count('running') + count('ready')
if (active === 0) {
return null
}
return (
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={() => host.navigate('/kanban')}
title={`Kanban — ${count('running')} running, ${count('ready')} ready`}
type="button"
>
<Codicon name="project" size="0.7rem" />
<span>{active}</span>
</button>
)
}
const plugin: HermesPlugin = {
id: 'kanban',
name: 'Kanban',
defaultEnabled: false,
register(ctx) {
bindApi(ctx.rest, ctx.storage, ctx.socket)
ctx.registerMany([
{
id: 'page',
area: ROUTES_AREA,
data: { path: '/kanban' } satisfies RouteContribution,
render: () => <KanbanBoardPage />
},
{
id: 'nav',
area: SIDEBAR_NAV_AREA,
order: 50,
data: { codicon: 'project', label: 'Kanban', path: '/kanban' } satisfies SidebarNavContribution
},
{
id: 'count',
area: STATUSBAR_AREAS.right,
order: 80,
render: () => <KanbanCount />
},
{
id: 'open',
area: PALETTE_AREA,
data: {
id: 'kanban.open',
label: 'Kanban: Open board',
keywords: ['kanban', 'board', 'tasks', 'agents'],
run: () => host.navigate('/kanban')
} satisfies PaletteContribution
}
])
}
}
export default plugin

View file

@ -0,0 +1,216 @@
/** The slice of the kanban REST contract the board renders. The backend
* (`plugins/kanban/dashboard/plugin_api.py`) returns much more per task; we
* type only what the UI reads so a schema addition never breaks the build. */
/** One card. `status` is the column id (see COLUMN_META). */
export interface KanbanTask {
id: string
title: string
body?: null | string
status: string
assignee?: null | string
priority?: number
tenant?: null | string
created_at?: number
latest_summary?: null | string
comment_count?: number
link_counts?: { parents: number; children: number }
/** N-of-M child completion, or null when the task has no children. */
progress?: null | { done: number; total: number }
/** Compact diagnostics rollup — present only when a card has warnings. */
warnings?: null | { count: number; highest_severity?: null | string }
/** Worker liveness (present on running cards) — drives the arc + run clock. */
started_at?: null | number
worker_pid?: null | number
last_heartbeat_at?: null | number
}
export interface KanbanColumn {
name: string
tasks: KanbanTask[]
}
export interface KanbanBoard {
columns: KanbanColumn[]
tenants: string[]
assignees: string[]
latest_event_id: number
now: number
}
/** A structured recovery action attached to a diagnostic. */
export interface DiagnosticAction {
kind: string
label: string
payload?: Record<string, unknown>
suggested?: boolean
}
/** One active distress signal on a task (kanban_diagnostics.Diagnostic). */
export interface Diagnostic {
kind: string
severity: 'critical' | 'error' | 'warning'
title: string
detail: string
actions: DiagnosticAction[]
count: number
last_seen_at: number
data: Record<string, unknown>
}
export interface KanbanRun {
id: number | string
profile?: null | string
status: string
outcome?: null | string
summary?: null | string
error?: null | string
metadata?: null | Record<string, unknown> | string
worker_pid?: null | number
started_at?: null | number
ended_at?: null | number
}
export interface KanbanComment {
id: number | string
author: string
body: string
created_at: number
}
export interface KanbanEvent {
id: number
kind: string
payload: unknown
created_at: number
}
export interface KanbanAttachment {
id: number | string
filename: string
size?: null | number
}
/** Fields present only on the detail endpoint (beyond the card's KanbanTask).
* `started_at`/`worker_pid`/`last_heartbeat_at` are inherited they live on
* KanbanTask now that the board's liveness arc reads them. */
export interface KanbanTaskFull extends KanbanTask {
result?: null | string
created_by?: null | string
completed_at?: null | number
last_failure_error?: null | string
workspace_kind?: null | string
workspace_path?: null | string
branch_name?: null | string
consecutive_failures?: number
diagnostics?: Diagnostic[]
}
/** GET /tasks/:id the task plus its related collections, which are SIBLINGS
* of `task`, not nested inside it. */
export interface KanbanTaskDetail {
task: KanbanTaskFull
comments: KanbanComment[]
events: KanbanEvent[]
attachments: KanbanAttachment[]
links: { parents: string[]; children: string[] }
runs: KanbanRun[]
}
/** GET /boards — every board on disk + which one is the server's current. */
export interface BoardMeta {
slug: string
name?: null | string
is_current?: boolean
total?: number
}
export interface BoardsResponse {
boards: BoardMeta[]
current: string
}
/** GET /tasks/:id/log — the worker's stdout/stderr tail. */
export interface WorkerLog {
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
/** GET /orchestration — dispatcher knobs from config.yaml + resolved values. */
export interface OrchestrationSettings {
orchestrator_profile: string
default_assignee: string
auto_decompose: boolean
resolved_orchestrator_profile: string
resolved_default_assignee: string
}
/** GET /profiles — the roster the decomposer routes across. */
export interface KanbanProfile {
name: string
is_default: boolean
description: string
description_auto: boolean
}
/** Column presentation: label + codicon + tone + one-line help. Order follows
* the backend's BOARD_COLUMNS; help text mirrors the dashboard so the workflow
* is self-explanatory (the board is a dispatcher queue, not a manual board).
* Anything the backend adds still renders via the fallback. */
export const COLUMN_META: Record<string, { label: string; codicon: string; tone: string; help: string }> = {
triage: {
label: 'Triage',
codicon: 'inbox',
tone: 'var(--ui-text-tertiary)',
help: 'Raw ideas — a specifier fleshes out the spec.'
},
todo: {
label: 'Todo',
codicon: 'circle-outline',
tone: 'var(--ui-text-secondary)',
help: 'Waiting on dependencies, or unassigned.'
},
scheduled: { label: 'Scheduled', codicon: 'watch', tone: '#a78bfa', help: 'Waiting for a scheduled time to arrive.' },
ready: {
label: 'Ready',
codicon: 'play-circle',
tone: '#60a5fa',
help: 'Dependencies satisfied — assign a profile and the dispatcher runs it.'
},
running: {
label: 'Running',
codicon: 'sync',
tone: '#34d399',
help: 'Claimed by a worker — an agent is on it. Set by the dispatcher.'
},
blocked: { label: 'Blocked', codicon: 'error', tone: '#f87171', help: 'The worker asked for human input.' },
review: {
label: 'Review',
codicon: 'eye',
tone: '#fbbf24',
help: 'A review agent is checking the work. Set by the dispatcher.'
},
done: {
label: 'Done',
codicon: 'pass',
tone: 'var(--ui-text-tertiary)',
help: 'Completed; dependent children become ready.'
},
archived: {
label: 'Archived',
codicon: 'archive',
tone: 'var(--ui-text-quaternary)',
help: 'Hidden from the default board view.'
}
}
export const columnMeta = (name: string) =>
COLUMN_META[name] ?? { label: name, codicon: 'circle-outline', tone: 'var(--ui-text-secondary)', help: '' }
export const SEVERITY_TONE: Record<Diagnostic['severity'], string> = {
critical: 'var(--destructive, #f87171)',
error: 'var(--destructive, #f87171)',
warning: '#fbbf24'
}

View file

@ -0,0 +1,329 @@
/** Shared kanban UI atoms: formatters, the identity avatar, the status menu,
* section chrome, and the masked scroller. Pure SDK + tokens. */
import {
coarseElapsed,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
profileColor,
profileColorSoft,
relativeTime,
useQuery
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { fetchOrchestration, ORCHESTRATION_KEY } from './api'
import { columnMeta, type KanbanTask } from './types'
/** Orchestration knobs (cached app-wide; the settings panel invalidates). */
export function useOrchestration() {
return useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration, staleTime: 60_000 }).data
}
/** The dispatcher's configured fallback for unassigned ready cards
* (`kanban.default_assignee`) '' when unset, i.e. unassigned never runs. */
export function useDefaultAssignee(): string {
return useOrchestration()?.default_assignee.trim() ?? ''
}
// System-owned drop targets — you can drag a card OUT of these, never INTO
// them, so lanes/menus must not offer them as targets. `running`/`review` are
// claimed by the dispatcher; `scheduled` needs a wake-up time only an agent or
// the CLI can attach (a bare status drag is refused with a 409).
export const LOCKED_COLUMNS: Record<string, string> = {
review: 'Review is entered by the dispatcher when a review agent takes the card.',
running: 'Running is set by the dispatcher when a worker claims the card.',
scheduled: 'Scheduled needs a wake-up time — agents set it; it cant be dragged into.'
}
export const isLockedTarget = (name: string): boolean => name in LOCKED_COLUMNS
export const shortId = (id?: null | string) => (id ?? '').replace(/^t_/, '').slice(0, 6)
// The electron REST bridge throws `Error("409: {\"detail\":\"…\"}")`; pull out
// the human-readable detail for a toast.
export function errText(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err)
const brace = raw.indexOf('{')
if (brace !== -1) {
try {
return (JSON.parse(raw.slice(brace)) as { detail?: string }).detail ?? raw
} catch {
// Not JSON — fall through to the raw message.
}
}
return raw
}
/** Backend timestamps are epoch SECONDS; the canonical formatter takes ms. */
export const ago = (seconds?: null | number): null | string => (seconds ? relativeTime(seconds * 1000) : null)
const ELAPSED_SUFFIX = { day: 'd', hour: 'h', minute: 'm', second: 's' } as const
/** Compact run duration ("42s", "3m") off the canonical elapsed bucketing. */
export function duration(start?: null | number, end?: null | number): null | string {
if (!start || !end || end < start) {
return null
}
const { unit, value } = coarseElapsed((end - start) * 1000)
return `${value}${ELAPSED_SUFFIX[unit]}`
}
// ── liveness ─────────────────────────────────────────────────────────────────
/** Live elapsed label ("34s", "2m") that keeps ticking while mounted. */
function useTicking(start?: null | number): null | string {
const [, force] = useState(0)
useEffect(() => {
if (!start) {
return
}
const id = window.setInterval(() => force(n => n + 1), 5_000)
return () => window.clearInterval(id)
}, [start])
if (!start) {
return null
}
const { unit, value } = coarseElapsed(Math.max(0, Date.now() - start * 1000))
return `${value}${ELAPSED_SUFFIX[unit]}`
}
export type ArcState = 'queued' | 'running' | 'stale'
/**
* The card's machine-activity state. The board looked dead between "I made a
* card" and "it's suddenly running" this narrates the in-between. Only the
* working states animate the border arc (see kanban.css): running = brisk
* sweep, no-heartbeat = amber crawl. `queued` (triage / assigned-ready /
* review) renders as the footer's named-agent chip motion means work.
*/
export function arcState(task: KanbanTask, fallbackAssignee: string): ArcState | null {
if (task.status === 'running') {
// No heartbeat for 2+ min = the worker may have died; the dispatcher will
// reclaim it, but be honest instead of sweeping green forever.
const stale = task.last_heartbeat_at ? Date.now() / 1000 - task.last_heartbeat_at > 120 : false
return stale ? 'stale' : 'running'
}
const queued =
task.status === 'triage' ||
task.status === 'review' ||
(task.status === 'ready' && Boolean(task.assignee || fallbackAssignee))
return queued ? 'queued' : null
}
export const ARC_TITLES = {
running: 'An agent is working on this now.',
stale: 'Claimed, but no worker heartbeat for 2+ minutes — the dispatcher will reclaim it.'
} as const
/** Ticking "working · 34s" line for running cards (elapsed since claim). */
export function RunClock({ task }: { task: KanbanTask }) {
const elapsed = useTicking(task.started_at)
if (!elapsed) {
return null
}
return (
<span className="shrink-0 font-medium" style={{ color: columnMeta('running').tone }}>
working · {elapsed}
</span>
)
}
function initials(name: string): string {
const parts = name
.trim()
.split(/[\s_\-./]+/)
.filter(Boolean)
return `${parts[0]?.[0] ?? '?'}${parts[1]?.[0] ?? ''}`.toUpperCase()
}
export function Avatar({ name, size = '1.25rem' }: { name: string; size?: string }) {
// Same identity hue the rest of the app uses (profileColor); default/empty
// profiles are neutral. Soft tag fill + colored glyph, per the app's tags.
const color = profileColor(name)
return (
<span
className="grid shrink-0 place-items-center rounded-full font-semibold"
style={{
backgroundColor: color ? profileColorSoft(color, 22) : 'var(--ui-bg-quaternary)',
color: color ?? 'var(--ui-text-secondary)',
fontSize: '0.5625rem',
height: size,
width: size
}}
title={name}
>
{initials(name)}
</span>
)
}
// Jira-style status control: a colored button showing the current state, click
// to transition. Options carry their column dot; the active one is checked.
export function StatusMenu({
columns,
onMove,
status
}: {
columns: string[]
onMove: (status: string) => void
status: string
}) {
const meta = columnMeta(status)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[0.6875rem] font-semibold uppercase tracking-wide transition-[filter] hover:brightness-105"
style={{ backgroundColor: `color-mix(in srgb, ${meta.tone} 15%, transparent)`, color: meta.tone }}
type="button"
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: meta.tone }} />
{meta.label}
<Codicon name="chevron-down" size="0.7rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{columns
.filter(name => name === status || !isLockedTarget(name))
.map(name => (
<DropdownMenuItem key={name} onSelect={() => onMove(name)}>
<span className="size-2 rounded-full" style={{ backgroundColor: columnMeta(name).tone }} />
{columnMeta(name).label}
{name === status && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// The board's one field/section-label style — hoisted so Section (here), the
// create dialog's Field, and the orchestration panel all read identically.
export const FIELD_LABEL = 'text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-(--ui-text-quaternary)'
export function Section({ action, children, label }: { action?: ReactNode; children: ReactNode; label: string }) {
return (
<section className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className={FIELD_LABEL}>{label}</div>
{action}
</div>
{children}
</section>
)
}
// Tinted advisory panel: a `tone`-washed body with a matching left rule and a
// tone-colored icon+title header. Shared by the drawer's diagnostics and its
// ready-but-unassigned warning so both read identically.
export function Callout({
children,
icon = 'warning',
title,
tone
}: {
children?: ReactNode
icon?: string
title: ReactNode
tone: string
}) {
return (
<div
className="flex flex-col gap-2 rounded-md p-2.5"
style={{ backgroundColor: `color-mix(in srgb, ${tone} 7%, transparent)`, borderLeft: `2px solid ${tone}` }}
>
<div className="flex items-start gap-1.5 text-[0.75rem] font-medium" style={{ color: tone }}>
<Codicon className="mt-px shrink-0" name={icon} size="0.8rem" />
<span>{title}</span>
</div>
{children}
</div>
)
}
// A short, edge-masked scroll area. The fades are EDGE-AWARE like the rest of
// the app: a gradient only appears on a side that actually has clipped content
// (nothing to scroll → no mask at all), tracked via scroll + resize. Plus
// `overscroll-contain` so scrolling it never chains into the drawer. When
// `deps` is provided it re-pins to the bottom on change — the activity feed's
// newest-at-bottom behavior.
export function ScrollFade({ children, deps, max = '9rem' }: { children: ReactNode; deps?: unknown; max?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [edges, setEdges] = useState({ above: false, below: false })
const measure = () => {
const el = ref.current
if (!el) {
return
}
const above = el.scrollTop > 1
const below = el.scrollTop + el.clientHeight < el.scrollHeight - 1
setEdges(prev => (prev.above === above && prev.below === below ? prev : { above, below }))
}
useLayoutEffect(() => {
if (deps !== undefined && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight
}
measure()
}, [deps])
useLayoutEffect(() => {
const el = ref.current
if (!el) {
return
}
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
const stops = [
edges.above ? 'transparent, black 1.25rem' : 'black',
edges.below ? 'calc(100% - 1.25rem), transparent' : 'black'
]
const mask = `linear-gradient(to bottom, ${stops[0]}, black ${stops[1]})`
return (
<div
className="overflow-y-auto overscroll-contain"
onScroll={measure}
ref={ref}
style={
edges.above || edges.below ? { maskImage: mask, maxHeight: max, WebkitMaskImage: mask } : { maxHeight: max }
}
>
{children}
</div>
)
}

View file

@ -2128,7 +2128,12 @@ def list_boards(include_archived: bool = Query(False)):
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
b["total"] = sum(b["counts"].values())
# Live cards only — archived tasks are hidden from every default
# board view, so advertising them in the switcher badge makes the
# two counts visibly disagree.
b["total"] = sum(
n for status, n in b["counts"].items() if status != "archived"
)
b["default_workspace_kind"] = _default_workspace_kind(b)
return {"boards": boards, "current": current}