diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 27ace696942..80a84b4c28a 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -67,7 +67,7 @@ import { closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' -import { $bindings } from '@/store/keybinds' +import { $bindings, bindingsFor } from '@/store/keybinds' import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' @@ -328,7 +328,9 @@ const PaletteRow = memo(function PaletteRow({ const Icon = item.icon // The row's live keybind, else a static modifier-variant hint (⌘↵). One slot, // so every downstream `ml-auto` fallback below keeps working unchanged. - const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint + // `bindingsFor`, not a raw lookup: a plugin's action is contributed after + // $bindings was seeded, so its combo only resolves through the fallback chain. + const combo = (item.action ? bindingsFor(item.action, bindings)[0] : undefined) ?? item.comboHint // While ⌘/⌃ is held, a row with a modifier variant previews it: the label // swaps to the variant's copy so Enter reads as what it will actually do. const modPreview = modHeld && Boolean(item.modLabel) diff --git a/apps/desktop/src/contrib/plugin.test.ts b/apps/desktop/src/contrib/plugin.test.ts new file mode 100644 index 00000000000..ca32f49e891 --- /dev/null +++ b/apps/desktop/src/contrib/plugin.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { createPluginContext } from './plugin' + +describe('createPluginContext.onDispose', () => { + it('collects arbitrary cleanups so the host runs them on deactivate', () => { + const disposers: Array<() => void> = [] + const ctx = createPluginContext('demo', dispose => disposers.push(dispose)) + + let cleaned = false + ctx.onDispose(() => { + cleaned = true + }) + + // The cleanup is tracked alongside contribution/socket disposers, so the + // loader's deactivate (which runs every collected disposer) tears it down. + expect(disposers).toHaveLength(1) + disposers.forEach(dispose => dispose()) + expect(cleaned).toBe(true) + }) +}) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index 551f626e62f..a88aa078823 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -40,6 +40,10 @@ export interface PluginContext { register: (c: PluginContribution) => () => void /** Register several at once; the returned disposer removes all of them. */ registerMany: (cs: PluginContribution[]) => () => void + /** Register an arbitrary cleanup to run on unload/disable — for side effects + * that aren't contributions or sockets (store subscriptions, timers). Runs + * alongside every other disposer when the plugin deactivates. */ + onDispose: (fn: () => void) => void /** REST to this plugin's own backend namespace (`/api/plugins/`); `path` * is relative ('/board'). The sanctioned door for a plugin that ships a * `plugin_api.py` — profile-aware, namespace-scoped by construction. Use @@ -108,6 +112,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () = source, register: c => track(registry.register(scope(c))), registerMany: cs => track(registry.registerMany(cs.map(scope))), + onDispose: fn => void track(fn), rest: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), storage: createPluginStorage(pluginId), diff --git a/apps/desktop/src/hooks/use-grab-scroll.ts b/apps/desktop/src/hooks/use-grab-scroll.ts new file mode 100644 index 00000000000..379fed3c289 --- /dev/null +++ b/apps/desktop/src/hooks/use-grab-scroll.ts @@ -0,0 +1,77 @@ +import { type MouseEvent as ReactMouseEvent, type RefObject, useState } from 'react' + +// Grab-to-pan for overflow containers — the shared primitive behind "scrub the +// board/timeline by dragging its background" (kanban lanes, trace waterfalls, +// wide tables). Sibling of lib/trackpad-gestures.ts: that file classifies +// wheel gestures, this one owns pointer-drag panning, so surfaces stop +// re-deriving the same interaction (the dashboard kanban and the agent-traces +// waterfall each hand-rolled a copy). +// +// Behavior contract: +// - drags translate scrollLeft/scrollTop (both axes, whichever overflow); +// - interactive targets never start a pan (buttons, inputs, links, +// [draggable] cards keep their own drag semantics); +// - the native scrollbar gutters stay untouched as the fallback affordance; +// - selection can't start mid-pan (preventDefault on move), and window +// blur/mouseup always end it. + +const BLOCKED_TARGETS = 'button,input,textarea,select,a,[role="button"],[draggable="true"]' +const SCROLLBAR_GUTTER_PX = 16 + +export interface GrabScroll { + /** True while a pan is in flight — drive `cursor-grabbing` styling. */ + grabbing: boolean + /** Spread onto the scroll container. */ + onMouseDown: (event: ReactMouseEvent) => void +} + +export function useGrabScroll(ref: RefObject): GrabScroll { + const [grabbing, setGrabbing] = useState(false) + + const onMouseDown = (event: ReactMouseEvent) => { + const el = ref.current + + if (event.button !== 0 || !el) { + return + } + + const canX = el.scrollWidth > el.clientWidth + const canY = el.scrollHeight > el.clientHeight + + if ((!canX && !canY) || (event.target as HTMLElement).closest(BLOCKED_TARGETS)) { + return + } + + const rect = el.getBoundingClientRect() + + if ( + (canX && event.clientY >= rect.bottom - SCROLLBAR_GUTTER_PX) || + (canY && event.clientX >= rect.right - SCROLLBAR_GUTTER_PX) + ) { + return + } + + const start = { left: el.scrollLeft, top: el.scrollTop, x: event.clientX, y: event.clientY } + setGrabbing(true) + + const onMove = (move: MouseEvent) => { + el.scrollLeft = start.left - (move.clientX - start.x) + el.scrollTop = start.top - (move.clientY - start.y) + move.preventDefault() + } + + const stop = () => { + setGrabbing(false) + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', stop) + window.removeEventListener('blur', stop) + } + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', stop, { once: true }) + window.addEventListener('blur', stop, { once: true }) + event.preventDefault() + } + + return { grabbing, onMouseDown } +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6733eb49a5d..5f7c0c5fcd6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2953,5 +2953,5 @@ export const en: Translations = { description: 'Displays the mobile sidebar.', toggle: open => `${open ? 'Show' : 'Hide'} sidebar` } - } + }, } diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fab8e071bf7..f61761f1d81 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2798,5 +2798,5 @@ export const ja = defineLocale({ description: 'モバイルサイドバーを表示します。', toggle: open => `サイドバーを${open ? '表示' : '非表示'}` } - } + }, }) diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8a808dffec8..cc572f8a55e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2684,5 +2684,5 @@ export const zhHant = defineLocale({ description: '顯示行動裝置側邊欄。', toggle: open => `${open ? '顯示' : '隱藏'}側邊欄` } - } + }, }) diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c34d811344e..c0eea0e79a2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3114,5 +3114,5 @@ export const zh: Translations = { description: '显示移动端侧边栏。', toggle: open => `${open ? '显示' : '隐藏'}侧边栏` } - } + }, } diff --git a/apps/desktop/src/lib/keybinds/contributed-actions.test.ts b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts new file mode 100644 index 00000000000..0b82242789f --- /dev/null +++ b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { createPluginContext } from '@/contrib/plugin' +import { registry } from '@/contrib/registry' +import { allKeybindActions, contributedKeybindHandler, KEYBINDS_AREA } from '@/lib/keybinds/actions' +import { bindingsFor } from '@/store/keybinds' + +// The plugin-command contract: a plugin ships a hotkey through the `keybinds` +// area and it behaves like a built-in — it dispatches, it resolves a combo for +// the palette hint, and it survives the plugin being unloaded. These assert the +// relationship between the pieces, not the specific chord any one plugin picks. +describe('contributed keybind actions', () => { + it('dispatches, resolves its default combo, and disappears on unload', () => { + const ctx = createPluginContext('demo') + let ran = 0 + + const dispose = ctx.register({ + id: 'new-thing', + area: KEYBINDS_AREA, + data: { + id: 'demo.newThing', + category: 'view', + defaults: ['mod+alt+n'], + label: 'Demo: New thing', + run: () => void (ran += 1) + } + }) + + // Dispatch path: use-keybinds looks the handler up by action id. + contributedKeybindHandler('demo.newThing')?.() + expect(ran).toBe(1) + + // Hint path: $bindings was seeded before this action existed, so only the + // resolver (default fallback) finds the combo — a raw store lookup can't. + expect(bindingsFor('demo.newThing')).toEqual(['mod+alt+n']) + + // Panel path: it shows up as a rebindable row alongside the built-ins. + expect(allKeybindActions().find(a => a.id === 'demo.newThing')?.label).toBe('Demo: New thing') + + dispose() + + expect(contributedKeybindHandler('demo.newThing')).toBeUndefined() + expect(allKeybindActions().some(a => a.id === 'demo.newThing')).toBe(false) + }) + + it('cannot shadow a built-in action id', () => { + const ctx = createPluginContext('demo') + + const dispose = ctx.register({ + id: 'steal-new-session', + area: KEYBINDS_AREA, + data: { id: 'session.new', defaults: ['mod+alt+n'], label: 'Demo: hijack', run: () => undefined } + }) + + // The built-in keeps its own combo and its own (i18n) label — the + // contribution is filtered out rather than overriding core. + expect(bindingsFor('session.new')).toEqual(['mod+n', 'shift+n']) + expect(allKeybindActions().filter(a => a.id === 'session.new')).toHaveLength(1) + + dispose() + }) + + it('leaves no registry residue between plugin loads', () => { + expect(registry.getArea(KEYBINDS_AREA).filter(c => c.source === 'plugin:demo')).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/lib/keybinds/use-keybind-hint.ts b/apps/desktop/src/lib/keybinds/use-keybind-hint.ts index ba6f0425be0..263c169c25a 100644 --- a/apps/desktop/src/lib/keybinds/use-keybind-hint.ts +++ b/apps/desktop/src/lib/keybinds/use-keybind-hint.ts @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' -import { $bindings } from '@/store/keybinds' +import { $registryVersion } from '@/contrib/registry' +import { $bindings, bindingsFor } from '@/store/keybinds' import { KEYBIND_READONLY } from './actions' import { formatCombo } from './combo' @@ -12,7 +13,14 @@ import { formatCombo } from './combo' export function useKeybindHint(actionId: string): string | null { const bindings = useStore($bindings) - const rebindable = bindings[actionId]?.[0] + // `bindingsFor`, not a raw `bindings[id]`: $bindings is seeded at module init + // from the actions known THEN, so a plugin action contributed later isn't in + // it and a raw lookup renders no hint at all. The resolver falls through to + // the stored override and the action's own defaults. Subscribing to the + // registry version repaints the hint when that late registration lands. + useStore($registryVersion) + + const rebindable = bindingsFor(actionId, bindings)[0] if (rebindable) { return formatCombo(rebindable) diff --git a/apps/desktop/src/plugins/example/plugin.tsx b/apps/desktop/src/plugins/example/plugin.tsx new file mode 100644 index 00000000000..22f6f9ba9d4 --- /dev/null +++ b/apps/desktop/src/plugins/example/plugin.tsx @@ -0,0 +1,121 @@ +/** + * Example plugin — the authoring + publishing reference. A folder under + * `src/plugins/` with a `plugin.tsx` that default-exports a `HermesPlugin` is + * all it takes; `discoverBundledPlugins()` finds and registers it (no import, + * no registry edit). Delete this folder and everything below is gone. + * + * The ONLY import surface is `@hermes/plugin-sdk` (lint-enforced) — the + * vscode-module model. This one plugin dogfoods the whole authoring kit: + * - `render()` contribution — full stateful React in a statusbar slot; + * - `ctx.storage` — the count survives reloads (namespaced persistence); + * - `host.onEvent('*')` — live gateway stream, counted in the tooltip; + * - PALETTE + KEYBINDS contracts — "Example: Reset click counter" in ⌘K + * and as a rebindable (default-unbound) action in the keybind panel; + * - plugin-local `atom` + `useValue` — module state, leaf subscription; + * - `haptic` / `host.notify` / `Tip` / `cn` — the design language. + */ + +import { + atom, + cn, + haptic, + type HermesPlugin, + host, + type KeybindContribution, + KEYBINDS_AREA, + PALETTE_AREA, + type PaletteContribution, + STATUSBAR_AREAS, + Tip, + useValue +} from '@hermes/plugin-sdk' + +const $clicks = atom(0) +const $events = atom(0) + +function ClickCounter() { + const count = useValue($clicks) + const events = useValue($events) + const gateway = useValue(host.state.gateway) + + return ( + + + + ) +} + +const plugin: HermesPlugin = { + id: 'example', + name: 'Example Plugin', + register(ctx) { + // Persisted count: hydrate once, write through on every change. + $clicks.set(ctx.storage.get('clicks', 0)) + $clicks.listen(clicks => ctx.storage.set('clicks', clicks)) + + // Hear the live gateway stream (deltas, lifecycle, tools — everything). + host.onEvent('*', () => $events.set($events.get() + 1)) + + const reset = () => { + $clicks.set(0) + host.notify({ kind: 'info', message: 'Example plugin: counter reset' }) + } + + // Provenance (source: 'plugin:example') and the namespaced registry ids + // (example:counter, …) are stamped by ctx — authors write plain + // contributions. The shared `example.reset` ACTION id links the palette + // row's hotkey hint to the keybind panel's live binding. + ctx.registerMany([ + { + id: 'counter', + area: STATUSBAR_AREAS.right, + order: 100, + render: () => + }, + { + id: 'reset', + area: PALETTE_AREA, + data: { + id: 'example.reset', + action: 'example.reset', + label: 'Example: Reset click counter', + keywords: ['example', 'plugin', 'counter'], + run: reset + } satisfies PaletteContribution + }, + { + id: 'reset', + area: KEYBINDS_AREA, + data: { + id: 'example.reset', + label: 'Example: Reset click counter', + defaults: [], + run: reset + } satisfies KeybindContribution + } + ]) + } +} + +export default plugin diff --git a/apps/desktop/src/plugins/gateway-pill/plugin.tsx b/apps/desktop/src/plugins/gateway-pill/plugin.tsx new file mode 100644 index 00000000000..60064f1c53b --- /dev/null +++ b/apps/desktop/src/plugins/gateway-pill/plugin.tsx @@ -0,0 +1,375 @@ +/** + * Gateway pill — the core statusbar gateway-health item implemented 1:1 as a + * plugin: same trigger chrome (declarative `variant: 'menu'` StatusbarItem → + * the app's own portal/popover plumbing, so nothing clips), same menu panel + * (connection/inference rows, restart, reason, RECENT ACTIVITY tail, + * messaging platforms), same copy (`useI18n`), same readiness logic + * (`evaluateRuntimeReadiness` over `host.request`). The point: a plugin can + * rebuild a REAL core feature through the SDK alone — only + * `@hermes/plugin-sdk` + react (lint-fenced). + * + * Pattern notes: + * - a module-level `atom` shares the readiness poll between the live label + * elements and the menu panel (the same primitive `host.state` uses); + * - label/detail/icon of a DATA item are ReactNodes, so they can be tiny + * components that subscribe — a static item shape with live innards. + */ + +import { + atom, + Button, + cn, + evaluateRuntimeReadiness, + type HermesPlugin, + host, + icons, + LogView, + type RuntimeReadinessResult, + type StatusbarItem, + StatusDot, + type StatusResponse, + type StatusTone, + Tip, + useI18n, + useValue +} from '@hermes/plugin-sdk' +import { type ReactNode, useEffect, useRef, useState } from 'react' + +const READINESS_POLL_MS = 15_000 +const LOG_TAIL = 120 +const LOG_VISIBLE = 40 +const LOG_POLL_MS = 3_000 + +// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything +// useful — strip it so the tail reads as real gateway activity at a glance. +const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i + +// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes. +const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/ +const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/ +const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '') + +const PLATFORM_TONE: Record = { + connected: 'good', + connecting: 'warn', + retrying: 'warn', + pending_restart: 'warn', + startup_failed: 'bad', + fatal: 'bad' +} + +const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase()) + +const SYSTEM_PANEL_ROUTE = '/command-center?section=system' + +// --------------------------------------------------------------------------- +// Readiness poll — one loop at plugin scope, shared by label + panel. +// --------------------------------------------------------------------------- + +const $readiness = atom(null) + +function startReadinessPoll() { + let timer: null | number = null + + const stop = () => { + if (timer !== null) { + window.clearInterval(timer) + timer = null + } + + $readiness.set(null) + } + + const refresh = () => + evaluateRuntimeReadiness(host.request) + .then(next => $readiness.set(next)) + .catch(() => undefined) + + const sync = (gateway: string) => { + if (gateway !== 'open') { + stop() + + return + } + + if (timer === null) { + void refresh() + timer = window.setInterval(() => void refresh(), READINESS_POLL_MS) + } + } + + sync(host.state.gateway.get()) + host.state.gateway.listen(sync) +} + +// --------------------------------------------------------------------------- +// Live trigger innards (the item is static DATA; these subscribe). +// --------------------------------------------------------------------------- + +function useHealth() { + const gateway = useValue(host.state.gateway) + const readiness = useValue($readiness) + + return { + connecting: gateway === 'connecting', + open: gateway === 'open', + readiness, + ready: gateway === 'open' && readiness?.ready === true + } +} + +function PillIcon() { + const { connecting, open, ready } = useHealth() + + return ( + + {ready ? : } + + ) +} + +function PillDetail() { + const { t } = useI18n() + const copy = t.shell.statusbar + const { connecting, open, readiness, ready } = useHealth() + + const detail = open + ? ready + ? copy.gatewayReady + : readiness + ? copy.gatewayNeedsSetup + : copy.gatewayChecking + : connecting + ? copy.gatewayConnecting + : copy.gatewayOffline + + return <>{detail} +} + +// --------------------------------------------------------------------------- +// The menu panel — the real GatewayMenuPanel, rebuilt on SDK doors. +// --------------------------------------------------------------------------- + +/** Live gui-log tail while the popover is mounted (i.e. open). */ +function useGatewayLogTail(): string[] { + const [lines, setLines] = useState([]) + + useEffect(() => { + let cancelled = false + + const load = () => + host + .logs({ file: 'gui', lines: LOG_TAIL }) + .then(res => { + if (!cancelled) { + setLines( + res.lines + .map(line => line.trim()) + .filter(line => line && !LOG_NOISE_RE.test(line)) + .slice(-LOG_VISIBLE) + ) + } + }) + .catch(() => undefined) + + void load() + const timer = window.setInterval(load, LOG_POLL_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, []) + + return lines +} + +function Section({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +function SectionLabel({ children }: { children: string }) { + return ( +
{children}
+ ) +} + +function GatewayMenuPanel({ onClose }: { onClose: () => void }) { + const { t } = useI18n() + const copy = t.shell.gatewayMenu + const gateway = useValue(host.state.gateway) + const { readiness, ready } = useHealth() + const [snapshot, setSnapshot] = useState(null) + const recentLogs = useGatewayLogTail() + + useEffect(() => { + void host + .status() + .then(setSnapshot) + .catch(() => undefined) + }, []) + + const openSystem = () => { + onClose() + host.navigate(SYSTEM_PANEL_ROUTE) + } + + const restart = () => { + onClose() + void host.restartGateway().catch(() => undefined) + } + + const gatewayOpen = gateway === 'open' + const gatewayConnecting = gateway === 'connecting' + + const connectionLabel = gatewayOpen + ? copy.connected + : gatewayConnecting + ? copy.connecting + : prettyState(gateway || copy.offline) + + const inferenceLabel = gatewayOpen + ? readiness?.ready + ? copy.inferenceReady + : readiness + ? copy.inferenceNotReady + : copy.checkingInference + : copy.disconnected + + const platforms = Object.entries(snapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r)) + + // Keep the tail pinned to the latest line as it streams. + const logScrollRef = useRef(null) + + useEffect(() => { + const el = logScrollRef.current + + if (el) { + el.scrollTop = el.scrollHeight + } + }, [recentLogs]) + + return ( +
+
+
+ + + {connectionLabel} + + + + {inferenceLabel} + +
+
+ + + + + + +
+
+ + {readiness?.reason && ( +
+
{readiness.reason}
+
+ )} + + {recentLogs.length > 0 && ( +
+
+ {copy.recentActivity} + +
+ + {recentLogs.map(trimLogLine).join('\n')} + +
+ )} + + {platforms.length > 0 && ( +
+ {copy.messagingPlatforms} +
    + {platforms.map(([name, platform]) => ( +
  • + {name} + + + {prettyState(platform.state)} + +
  • + ))} +
+
+ )} +
+ ) +} + +function PillLabel() { + const { t } = useI18n() + + return <>{t.shell.statusbar.gateway} +} + +// --------------------------------------------------------------------------- + +const plugin: HermesPlugin = { + id: 'gateway-pill', + name: 'Gateway Pill', + register(ctx) { + startReadinessPoll() + + // Declarative menu item — the app's own trigger/popover chrome renders it + // (portal, w-72, side=top), the plugin supplies live innards + the panel. + ctx.register({ + id: 'pill', + area: 'statusBar.right', + order: 90, + data: { + icon: , + id: 'gateway-pill', + label: , + detail: , + menuClassName: 'w-72', + menuContent: (close: () => void) => , + variant: 'menu' + } satisfies StatusbarItem + }) + } +} + +export default plugin diff --git a/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js b/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js new file mode 100644 index 00000000000..6647138e1fe --- /dev/null +++ b/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js @@ -0,0 +1,38 @@ +/** + * Runtime-loaded example — this file is NOT bundled as a module: it ships as + * raw text (`?raw`) and goes through the real runtime pipeline (specifier + * rewrite -> SDK/react shim blobs -> blob import -> register). Plain ESM js + * with `jsx()` calls — exactly what an agent (or a compiler) writes into + * `~/.hermes/desktop-plugins//plugin.js`. + */ + +import { cn, host, Tip, useValue } from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' + +function RuntimeChip() { + const gateway = useValue(host.state.gateway) + + return jsx(Tip, { + label: `Loaded at RUNTIME through blob import + SDK injection (gateway: ${gateway})`, + children: jsxs('span', { + className: cn( + 'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem]', + 'text-(--ui-text-tertiary)' + ), + children: [jsx('span', { 'aria-hidden': true, children: '⚡' }), jsx('span', { children: 'runtime' })] + }) + }) +} + +export default { + id: 'hello-runtime', + name: 'Hello Runtime', + register(ctx) { + ctx.register({ + id: 'chip', + area: 'statusBar.right', + order: 110, + render: () => jsx(RuntimeChip, {}) + }) + } +} diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts new file mode 100644 index 00000000000..16403bbc4ee --- /dev/null +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -0,0 +1,253 @@ +/** + * 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 { + BoardMeta, + BoardsResponse, + KanbanBoard, + KanbanProfile, + KanbanProject, + KanbanTask, + KanbanTaskDetail, + OrchestrationSettings, + TaskEstimate, + 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!) }) + } +} + +// A persisted, subscribable atom (the structural slice we need — avoids +// importing nanostore's type just to describe one). +interface Persisted { + get(): T + set(value: T): void + listen(cb: (value: T) => void): () => void +} + +/** Bind the plugin's doors at register time and return a disposer the host + * runs on unload/disable — so nothing (store sync, socket) survives a toggle + * or duplicates on re-enable. 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 + const unsubs: Array<() => void> = [] + + // Hydrate an atom from storage and keep storage in sync with it. + const persist = (atom: Persisted, key: string, fallback: T) => { + atom.set(storage.get(key, fallback)) + unsubs.push(atom.listen(value => storage.set(key, value))) + } + + persist($boardSlug, BOARD_SLUG_KEY, '') + persist($introDismissed, INTRO_KEY, false) + persist($lanesByProfile, LANES_KEY, false) + persist($collapsedLanes, COLLAPSED_KEY, {}) + + 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()) + unsubs.push($boardSlug.listen(open)) + + return () => { + unsubs.forEach(unsub => unsub()) + close?.() + rest = null + } +} + +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 PROJECTS_KEY = ['kanban', 'projects'] 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') + +/** First-class Hermes projects, for scoping a board's default workspace. */ +export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects') + +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, projectId?: string) => + call<{ board: { slug: string } }>('/boards', { + method: 'POST', + body: { slug, name, ...(projectId ? { project_id: projectId } : {}) } + }) + +/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a + * model call — gate behind an explicit user action + disclaimer. */ +export const estimateTask = (id: string) => + call(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} }) + +/** Estimate from typed title/body before a task exists (create dialog). */ +export const estimateNew = (title: string, body: string) => + call('/estimate', { method: 'POST', body: { title, body: body || undefined } }) + +/** Edit a board's display metadata + default project directory. Pass + * `default_workdir: ''` to clear it. Slug is immutable. */ +export const updateBoard = (slug: string, patch: Record) => + call<{ board: BoardMeta }>(`/boards/${encodeURIComponent(slug)}`, { method: 'PATCH', body: patch }) + +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..6fe961eee81 --- /dev/null +++ b/apps/desktop/src/plugins/kanban/board-switcher.tsx @@ -0,0 +1,243 @@ +/** + * 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, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + useMutation, + useQuery, + useQueryClient, + useValue +} from '@hermes/plugin-sdk' +import { useEffect, useState } from 'react' + +import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards, fetchProjects, PROJECTS_KEY, updateBoard } from './api' +import type { BoardMeta } from './types' +import { errText, FIELD_LABEL, useKanban } from './ui' + +const NO_PROJECT = '__none__' + +/** Board scope = a first-class Hermes project. Its primary repo becomes the + * board's default workspace root; new tasks inherit it as a worktree with a + * deterministic branch. "No project" falls back to scratch sandboxes. */ +function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; value: string }) { + const k = useKanban() + const { data } = useQuery({ queryKey: PROJECTS_KEY, queryFn: fetchProjects, staleTime: 30_000 }) + const projects = data?.projects ?? [] + + return ( + + ) +} + +function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean }) { + const k = useKanban() + const qc = useQueryClient() + const [name, setName] = useState('') + const [project, setProject] = useState('') + + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + + useEffect(() => { + if (open) { + setName('') + setProject('') + } + }, [open]) + + const create = useMutation({ + mutationFn: () => createBoard(slug, name.trim(), project || undefined), + 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}> + + + {k.newBoard} + +
+ + +
+ + + + +
+
+ ) +} + +function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onClose: () => void }) { + const k = useKanban() + const qc = useQueryClient() + const [name, setName] = useState('') + const [project, setProject] = useState('') + + useEffect(() => { + if (board) { + setName(board.name || '') + setProject(board.project_id || '') + } + }, [board]) + + const save = useMutation({ + // Slug is immutable; send name + project_id ('' clears the scope, which + // also drops the mirrored default_workdir on the backend). + mutationFn: () => updateBoard(board!.slug, { name: name.trim(), project_id: project }), + onError: err => host.notify({ kind: 'error', message: errText(err) }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: BOARDS_KEY }) + onClose() + } + }) + + return ( + !o && onClose()} open={Boolean(board)}> + + + {board ? k.boardSettingsFor(board.name || board.slug) : k.boardSettings} + +
+ + +
+ + + + +
+
+ ) +} + +export function BoardSwitcher() { + const k = useKanban() + const slug = useValue($boardSlug) + const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 }) + const [adding, setAdding] = useState(false) + const [settingsFor, setSettingsFor] = useState(null) + + if (!boards) { + return null + } + + const currentSlug = slug || boards.current + const current = boards.boards.find(meta => meta.slug === currentSlug) + const label = current?.name || current?.slug || k.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 && } + + ))} + + {current && ( + setSettingsFor(current)}> + + {k.boardSettings} + + )} + setAdding(true)}> + + {k.newBoardDots} + + + + setAdding(false)} open={adding} /> + setSettingsFor(null)} /> + + ) +} diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx new file mode 100644 index 00000000000..fa53a26f969 --- /dev/null +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -0,0 +1,1411 @@ +/** + * 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, + compactNumber, + 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, + BOARDS_KEY, + bulkTasks, + createTask, + deleteTask, + estimateNew, + fetchBoard, + fetchBoards, + 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, type TaskEstimate } from './types' +import { + $newTaskLane, + ago, + type ArcState, + arcState, + Avatar, + columnHelp, + columnLabel, + errText, + FIELD_LABEL, + isLockedTarget, + lockedReason, + RunClock, + shortId, + useDefaultAssignee, + useKanban, + 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 k = useKanban() + 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' && ( + + {k.noHeartbeat} + + )} + {unassignedReady && !fallback && ( + + + + {k.wontRun} + + + )} +
+ {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 k = useKanban() + 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)}> + + {k.open} + + onToggleSelect(task.id)}> + + {selected ? k.deselect : k.select} + + + {columns + .filter(name => name !== task.status && !isLockedTarget(name)) + .map(name => ( + onMove(task.id, name)}> + + {k.moveTo(columnLabel(k, name))} + + ))} + + onDelete(task.id)} variant="destructive"> + + {k.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 k = useKanban() + const [over, setOver] = useState(false) + const meta = columnMeta(column.name) + const label = columnLabel(k, 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 ( +
+
+ + + + {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 && ( +
+ {k.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 k = useKanban() + 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' + + // Board-level workspace default: a task inherits the current board's + // configured project dir (scratch when unset, worktree in a git repo, else + // dir) unless the operator overrides it below. Set the board default in the + // board switcher's "Board settings…". + const selectedSlug = useValue($boardSlug) + const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 }) + const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current)) + const boardDefaultKind = currentBoard?.default_workspace_kind || 'scratch' + const boardDefaultDir = currentBoard?.default_workdir || '' + + 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(boardDefaultKind) + // Empty = inherit the board's default project dir (backend resolves it); + // a path here overrides just this task. Only meaningful for dir/worktree. + const [workspacePath, setWorkspacePath] = useState('') + const [parent, setParent] = useState('') + const [goalMode, setGoalMode] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [estimate, setEstimate] = useState(null) + + // Rough effort estimate from the typed title/body (before the task exists), + // via the auto-routed auxiliary model. Makes a model call — explicit action. + const estMut = useMutation({ + mutationFn: () => estimateNew(title.trim(), bodyText.trim()), + onError: err => host.notify({ kind: 'error', message: errText(err) }), + onSuccess: r => { + if (r.ok) { + setEstimate(r) + } else { + host.notify({ kind: 'warning', message: r.reason || k.couldNotEstimate }) + } + } + }) + + // Reset per open — the dialog is externally controlled (open = target set), + // so onOpenChange(true) never fires; key the reset off `target` (and the + // resolved board default, which may arrive after the first open). + useEffect(() => { + if (target) { + setTitle('') + setBodyText('') + setAssignee('') + setPriority('0') + setSkills('') + setWorkspaceKind(boardDefaultKind) + setWorkspacePath('') + setParent('') + setGoalMode(false) + setError(null) + setBusy(false) + setEstimate(null) + } + }, [target, boardDefaultKind]) + + 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, + // Empty → backend inherits the board's default project dir. + workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined + }) + + 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)}> + + + {target ? k.newTaskIn(columnLabel(k, target)) : k.newTask} + +
+ setTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } + }} + placeholder={isTriage ? k.titlePlaceholderTriage : k.titlePlaceholder} + value={title} + /> +