mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
81aacdef4d
commit
79e7adae2d
10 changed files with 3325 additions and 1 deletions
209
apps/desktop/src/plugins/kanban/api.ts
Normal file
209
apps/desktop/src/plugins/kanban/api.ts
Normal 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 } }
|
||||
)
|
||||
136
apps/desktop/src/plugins/kanban/board-switcher.tsx
Normal file
136
apps/desktop/src/plugins/kanban/board-switcher.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
1283
apps/desktop/src/plugins/kanban/board.tsx
Normal file
1283
apps/desktop/src/plugins/kanban/board.tsx
Normal file
File diff suppressed because it is too large
Load diff
808
apps/desktop/src/plugins/kanban/drawer.tsx
Normal file
808
apps/desktop/src/plugins/kanban/drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
apps/desktop/src/plugins/kanban/kanban.css
Normal file
46
apps/desktop/src/plugins/kanban/kanban.css
Normal 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;
|
||||
}
|
||||
}
|
||||
179
apps/desktop/src/plugins/kanban/orchestration.tsx
Normal file
179
apps/desktop/src/plugins/kanban/orchestration.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
apps/desktop/src/plugins/kanban/plugin.tsx
Normal file
113
apps/desktop/src/plugins/kanban/plugin.tsx
Normal 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
|
||||
216
apps/desktop/src/plugins/kanban/types.ts
Normal file
216
apps/desktop/src/plugins/kanban/types.ts
Normal 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'
|
||||
}
|
||||
329
apps/desktop/src/plugins/kanban/ui.tsx
Normal file
329
apps/desktop/src/plugins/kanban/ui.tsx
Normal 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 can’t 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue