diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts new file mode 100644 index 00000000000..bc46657277f --- /dev/null +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -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=` 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 = (path: string, opts?: PluginRestOptions) => Promise +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('') + +/** Whether the "how this board works" intro was dismissed. Persisted. */ +export const $introDismissed = atom(false) + +/** Sub-group the Running lane by assignee (the dashboard's "lanes by + * profile"). Persisted. */ +export const $lanesByProfile = atom(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>({}) + +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(path: string, opts?: PluginRestOptions): Promise { + return rest ? rest(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 { + 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(withBoard('/board', archived ? { include_archived: 'true' } : {})) + +export const fetchTask = (id: string) => call(withBoard(`/tasks/${id}`)) + +/** Worker stdout/stderr tail (last 16 KiB — plenty for the drawer). */ +export const fetchLog = (id: string) => call(withBoard(`/tasks/${id}/log`, { tail: '16384' })) + +export const fetchBoards = () => call('/boards') + +export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles') + +export const fetchOrchestration = () => call('/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 = 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(write: Promise): Promise { + return write.then(value => { + autoNudge() + + return value + }) +} + +export const patchTask = (id: string, patch: Record) => + nudged(call(withBoard(`/tasks/${id}`), { method: 'PATCH', body: patch })) + +export const createTask = (body: Record) => + 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) => + 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) => + call('/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 } } + ) diff --git a/apps/desktop/src/plugins/kanban/board-switcher.tsx b/apps/desktop/src/plugins/kanban/board-switcher.tsx new file mode 100644 index 00000000000..c76625c1129 --- /dev/null +++ b/apps/desktop/src/plugins/kanban/board-switcher.tsx @@ -0,0 +1,136 @@ +/** + * Titlebar board switcher — the board page projects this into `titleBar.center` + * (where chat shows the session-title dropdown) via ``, 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 ( + !o && onClose()} open={open}> + + + New board + +
+ setName(event.target.value)} + onKeyDown={event => event.key === 'Enter' && slug && create.mutate()} + placeholder="Board name" + value={name} + /> + {slug && slug: {slug}} +
+ + + + +
+
+ ) +} + +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 ( + <> + + + + + + {boards.boards.map(meta => ( + $boardSlug.set(meta.slug === boards.current ? '' : meta.slug)} + > + {meta.name || meta.slug} + {typeof meta.total === 'number' && ( + {meta.total} + )} + {meta.slug === currentSlug && } + + ))} + + setAdding(true)}> + + New board… + + + + setAdding(false)} open={adding} /> + + ) +} diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx new file mode 100644 index 00000000000..d0213466c4a --- /dev/null +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -0,0 +1,1283 @@ +/** + * The Kanban board page — mounted at `/kanban` (a ROUTES_AREA contribution) in + * the workspace pane. The desktop port of the dashboard board: one compact + * header row (count, filter kebab, search, settings, new task — the board + * SWITCHER lives in the titlebar, see board-switcher.tsx), columns in + * BOARD_COLUMNS order, drag-to-move (optimistic, workflow-checked), + * ⌘-click multi-select with a floating bulk bar, right-click actions, and + * the detail drawer. Dispatch nudges ride every write (see api.ts). + */ + +import { + Button, + cn, + Codicon, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + Contribute, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + ErrorState, + host, + Input, + Loader, + SearchField, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Textarea, + Tip, + TITLEBAR_AREAS, + useGrabScroll, + useMutation, + useQuery, + useQueryClient, + useValue +} from '@hermes/plugin-sdk' +import { + type CSSProperties, + type DragEvent as ReactDragEvent, + type ReactNode, + useEffect, + useMemo, + useRef, + useState +} from 'react' + +import { + $boardSlug, + $collapsedLanes, + $introDismissed, + $lanesByProfile, + boardKey, + bulkTasks, + createTask, + deleteTask, + fetchBoard, + fetchProfiles, + patchTask, + PROFILES_KEY +} from './api' +import { BoardSwitcher } from './board-switcher' +import { TaskDrawer } from './drawer' +import { OrchestrationPanel } from './orchestration' +import { columnMeta, type KanbanBoard, type KanbanTask } from './types' +import { + ago, + ARC_TITLES, + type ArcState, + arcState, + Avatar, + errText, + FIELD_LABEL, + isLockedTarget, + LOCKED_COLUMNS, + RunClock, + shortId, + useDefaultAssignee, + useOrchestration +} from './ui' + +// ── optimistic board edits (reconciled by the follow-up refresh) ───────────── + +function moveCard(board: KanbanBoard, id: string, toStatus: string): KanbanBoard { + let moved: KanbanTask | undefined + + const columns = board.columns.map(col => ({ + ...col, + tasks: col.tasks.filter(task => { + if (task.id !== id) { + return true + } + + moved = { ...task, status: toStatus } + + return false + }) + })) + + if (!moved) { + return board + } + + return { + ...board, + columns: columns.map(col => (col.name === toStatus ? { ...col, tasks: [moved!, ...col.tasks] } : col)) + } +} + +function removeCard(board: KanbanBoard, id: string): KanbanBoard { + return { ...board, columns: board.columns.map(col => ({ ...col, tasks: col.tasks.filter(t => t.id !== id) })) } +} + +// ── card ───────────────────────────────────────────────────────────────────── + +function Meta({ children, icon }: { children: ReactNode; icon: string }) { + return ( + + + {children} + + ) +} + +function CardFooter({ arc, task }: { arc: ArcState | null; task: KanbanTask }) { + const created = ago(task.created_at) + const links = task.link_counts ? task.link_counts.parents + task.link_counts.children : 0 + const fallback = useDefaultAssignee() + const orchestrator = useOrchestration()?.resolved_orchestrator_profile ?? '' + // Ready + no assignee: with a configured default assignee the dispatcher + // auto-assigns on its next tick (#27145) — say THAT, not "won't run". Only + // a board with no fallback has the genuine silent failure. + const unassignedReady = task.status === 'ready' && !task.assignee + + // The agent on the hook for a queued card: the explicit assignee, else the + // auto-default (ready), else the specifier that rewrites triage cards. + const attached = task.assignee || (task.status === 'ready' ? fallback : task.status === 'triage' ? orchestrator : '') + + const meta = columnMeta(task.status) + + return ( +
+ {arc === 'queued' && attached ? ( + // WHO is coming for the card. The arc only animates once the agent is + // actually working; while queued, the named chip carries "attached". + + + + + {!task.assignee && '→ '} + {attached} + + + + ) : task.assignee ? ( + + ) : null} + {arc === 'running' && ( + + + + + + )} + {arc === 'stale' && ( + + no heartbeat + + )} + {unassignedReady && !fallback && ( + + + + won't run + + + )} +
+ {typeof task.priority === 'number' && task.priority > 0 && ( + + + {task.priority} + + )} + {task.progress && task.progress.total > 0 && ( + + {task.progress.done}/{task.progress.total} + + )} + {Boolean(task.comment_count) && {task.comment_count}} + {links > 0 && {links}} + {task.warnings && task.warnings.count > 0 && ( + + + {task.warnings.count} + + )} + {created && !task.assignee && !unassignedReady ? ( + {created} + ) : null} + {shortId(task.id)} +
+
+ ) +} + +function Card({ + columns, + onDelete, + onMove, + onOpen, + onToggleSelect, + selected, + task +}: { + columns: string[] + onDelete: (id: string) => void + onMove: (id: string, status: string) => void + onOpen: (id: string) => void + onToggleSelect: (id: string) => void + selected: boolean + task: KanbanTask +}) { + const [dragging, setDragging] = useState(false) + const meta = columnMeta(task.status) + const summary = task.latest_summary || task.body + const fallback = useDefaultAssignee() + const arc = arcState(task, fallback) + + return ( + + +
(event.metaKey || event.ctrlKey ? onToggleSelect(task.id) : onOpen(task.id))} + onDragEnd={() => setDragging(false)} + onDragStart={event => { + event.dataTransfer.setData('text/plain', task.id) + event.dataTransfer.effectAllowed = 'move' + // Snapshot the drag image before dimming the source, so the ghost + // stays a solid card (dimming first would bake 40% into it). + event.dataTransfer.setDragImage(event.currentTarget, event.nativeEvent.offsetX, event.nativeEvent.offsetY) + setDragging(true) + }} + style={{ '--kanban-tone': meta.tone, borderLeftColor: meta.tone } as CSSProperties} + > + {/* Machine-activity arc: animates ONLY while an agent is actually on + the card (claimed + working; amber when the heartbeat is gone). + Queued attachment is the footer's named-agent chip — a moving + border on an idle card would lie. Hidden during drag/selection + so those states stay legible. */} + {(arc === 'running' || arc === 'stale') && !dragging && !selected && ( + + )} + + {task.title || task.id} + + {summary && ( + {summary} + )} + +
+
+ + onOpen(task.id)}> + + Open + + onToggleSelect(task.id)}> + + {selected ? 'Deselect' : 'Select (⌘-click)'} + + + {columns + .filter(name => name !== task.status && !isLockedTarget(name)) + .map(name => ( + onMove(task.id, name)}> + + Move to {columnMeta(name).label} + + ))} + + onDelete(task.id)} variant="destructive"> + + Delete + + +
+ ) +} + +// ── column ─────────────────────────────────────────────────────────────────── + +function Column({ + collapsed, + column, + columns, + onAdd, + onDelete, + onDropTask, + onMove, + onOpen, + onToggle, + onToggleSelect, + selected +}: { + collapsed: boolean + column: { name: string; tasks: KanbanTask[] } + columns: string[] + onAdd: (status: string) => void + onDelete: (id: string) => void + onDropTask: (id: string, status: string) => void + onMove: (id: string, status: string) => void + onOpen: (id: string) => void + onToggle: () => void + onToggleSelect: (id: string) => void + selected: ReadonlySet +}) { + const [over, setOver] = useState(false) + const meta = columnMeta(column.name) + const locked = isLockedTarget(column.name) + const byProfile = useValue($lanesByProfile) + + // The dashboard's "lanes by profile": sub-group Running by assignee so a + // fleet's in-flight work reads per-worker. Null = flat (off, or trivial). + const lanes = useMemo(() => { + if (!byProfile || column.name !== 'running' || column.tasks.length === 0) { + return null + } + + const groups = new Map() + + for (const task of column.tasks) { + const key = task.assignee || UNASSIGNED_LANE + groups.set(key, [...(groups.get(key) ?? []), task]) + } + + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)) + }, [byProfile, column]) + + const dragHandlers = { + onDragLeave: () => setOver(false), + onDragOver: (event: ReactDragEvent) => { + // Locked lanes don't preventDefault → the OS shows the no-drop cursor + // and the drop event never fires. The lane is honest about itself. + if (locked) { + event.dataTransfer.dropEffect = 'none' + + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setOver(true) + }, + onDrop: (event: ReactDragEvent) => { + event.preventDefault() + setOver(false) + const id = event.dataTransfer.getData('text/plain') + + if (id) { + onDropTask(id, column.name) + } + } + } + + const wash = over && !locked ? 'bg-(--ui-bg-quinary)' : 'bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]' + + // Collapsed = a thin vertical rail: dot, sideways label, count. Still a live + // drop target (drop straight onto the rail); click expands. The dot sits in + // the same h-5 header row as an expanded lane's, so dots align across the + // board regardless of collapse state. + if (collapsed) { + return ( + + ) + } + + return ( +
+
+ + + + {meta.label} + + + {column.tasks.length} + +
+
+ {lanes + ? lanes.map(([assignee, tasks]) => ( +
+
+ {assignee !== UNASSIGNED_LANE && } + {assignee} + {tasks.length} +
+ {tasks.map(task => ( + + ))} +
+ )) + : column.tasks.map(task => ( + + ))} + {/* Jira-style lane add — dashed, faded in on lane hover. Opacity (not + display) so it always holds its slot and never thrashes layout. + Locked lanes get none: you can't create into a system state. */} + {!locked && ( + + )} + {column.tasks.length === 0 && ( +
+ Empty +
+ )} +
+
+ ) +} + +// ── dialogs ────────────────────────────────────────────────────────────────── + +const NO_PARENT = '__none__' +const PARKED = '__parked__' +const WORKSPACE_KINDS = ['scratch', 'worktree', 'dir'] as const + +function Field({ children, label }: { children: ReactNode; label: string }) { + return ( + + ) +} + +function NewTaskDialog({ + onClose, + parents, + target +}: { + onClose: () => void + parents: Array<{ id: string; title: string }> + target: null | string +}) { + const qc = useQueryClient() + const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 }) + // Title-only creates must RUN: "auto" resolves to the orchestration default + // (ultimately the active profile), applied at create time. Never silently + // unassigned — parking a card is the explicit choice, not the default. + const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default' + const isTriage = target === 'triage' + const [title, setTitle] = useState('') + const [bodyText, setBodyText] = useState('') + const [assignee, setAssignee] = useState('') + const [priority, setPriority] = useState('0') + const [skills, setSkills] = useState('') + const [workspaceKind, setWorkspaceKind] = useState('scratch') + const [parent, setParent] = useState('') + const [goalMode, setGoalMode] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + // Reset per open — the dialog is externally controlled (open = target set), + // so onOpenChange(true) never fires; key the reset off `target` instead. + useEffect(() => { + if (target) { + setTitle('') + setBodyText('') + setAssignee('') + setPriority('0') + setSkills('') + setWorkspaceKind('scratch') + setParent('') + setGoalMode(false) + setError(null) + setBusy(false) + } + }, [target]) + + const submit = async () => { + const trimmed = title.trim() + + if (!trimmed || !target || busy) { + return + } + + setBusy(true) + setError(null) + + try { + const skillList = skills + .split(',') + .map(s => s.trim()) + .filter(Boolean) + + // create() derives status (triage flag → 'triage', else 'ready'); move to + // the requested column when they differ, so a per-column add lands right. + const { task, warning } = await createTask({ + assignee: assignee === PARKED ? undefined : assignee || resolvedDefault, + body: bodyText.trim() || undefined, + goal_mode: goalMode, + parents: parent ? [parent] : undefined, + priority: Number(priority) || 0, + skills: skillList.length ? skillList : undefined, + title: trimmed, + triage: isTriage, + workspace_kind: workspaceKind + }) + + if (task && task.status !== target) { + await patchTask(task.id, { status: target }) + } + + // Dispatcher-presence warning ("this ready task will sit idle") — not an + // error, but the user should know. + if (warning) { + host.notify({ kind: 'warning', message: warning }) + } + + await qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) + onClose() + } catch (err) { + setError(errText(err)) + setBusy(false) + } + } + + return ( + !open && onClose()} open={Boolean(target)}> + + + New task{target ? ` in ${columnMeta(target).label}` : ''} + +
+ setTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } + }} + placeholder={isTriage ? 'Rough idea — a specifier will flesh it out' : 'Title'} + value={title} + /> +