mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #61173 from NousResearch/bb/desktop-kanban
feat(desktop): Kanban — the founding plugin on the desktop SDK
This commit is contained in:
commit
0324849fe4
33 changed files with 6334 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
21
apps/desktop/src/contrib/plugin.test.ts
Normal file
21
apps/desktop/src/contrib/plugin.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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/<id>`); `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: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
|
||||
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
|
||||
storage: createPluginStorage(pluginId),
|
||||
|
|
|
|||
77
apps/desktop/src/hooks/use-grab-scroll.ts
Normal file
77
apps/desktop/src/hooks/use-grab-scroll.ts
Normal file
|
|
@ -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<HTMLElement | null>): 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 }
|
||||
}
|
||||
|
|
@ -2953,5 +2953,5 @@ export const en: Translations = {
|
|||
description: 'Displays the mobile sidebar.',
|
||||
toggle: open => `${open ? 'Show' : 'Hide'} sidebar`
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2798,5 +2798,5 @@ export const ja = defineLocale({
|
|||
description: 'モバイルサイドバーを表示します。',
|
||||
toggle: open => `サイドバーを${open ? '表示' : '非表示'}`
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2684,5 +2684,5 @@ export const zhHant = defineLocale({
|
|||
description: '顯示行動裝置側邊欄。',
|
||||
toggle: open => `${open ? '顯示' : '隱藏'}側邊欄`
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3114,5 +3114,5 @@ export const zh: Translations = {
|
|||
description: '显示移动端侧边栏。',
|
||||
toggle: open => `${open ? '显示' : '隐藏'}侧边栏`
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
66
apps/desktop/src/lib/keybinds/contributed-actions.test.ts
Normal file
66
apps/desktop/src/lib/keybinds/contributed-actions.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
121
apps/desktop/src/plugins/example/plugin.tsx
Normal file
121
apps/desktop/src/plugins/example/plugin.tsx
Normal file
|
|
@ -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 (
|
||||
<Tip label={`Example plugin — gateway ${gateway}, ${events} events heard`}>
|
||||
<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',
|
||||
count > 0 && 'text-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
haptic('tap')
|
||||
// Imperative read in the handler ($atom.get()), reactive read in the
|
||||
// render (useValue) — never a stale closure.
|
||||
const next = $clicks.get() + 1
|
||||
$clicks.set(next)
|
||||
|
||||
if (next % 10 === 0) {
|
||||
host.notify({ kind: 'success', message: `Example plugin: ${next} clicks!` })
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden>◉</span>
|
||||
<span>{count === 0 ? 'click me' : `clicked ${count}×`}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
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: () => <ClickCounter />
|
||||
},
|
||||
{
|
||||
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
|
||||
375
apps/desktop/src/plugins/gateway-pill/plugin.tsx
Normal file
375
apps/desktop/src/plugins/gateway-pill/plugin.tsx
Normal file
|
|
@ -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<string, StatusTone> = {
|
||||
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 | RuntimeReadinessResult>(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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex',
|
||||
ready ? 'text-(--ui-text-tertiary)' : open || connecting ? 'text-amber-500' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{ready ? <icons.Activity className="size-3" /> : <icons.AlertCircle className="size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<string[]>([])
|
||||
|
||||
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 <div className={cn('border-t border-border/50 px-3 py-2', className)}>{children}</div>
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 | StatusResponse>(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<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = logScrollRef.current
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [recentLogs])
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-1 text-[0.7rem] leading-none">
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<StatusDot tone={gatewayOpen ? 'good' : gatewayConnecting ? 'warn' : 'bad'} />
|
||||
{connectionLabel}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<StatusDot tone={ready ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
|
||||
{inferenceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tip label={t.commandCenter.restartGateway}>
|
||||
<Button
|
||||
aria-label={t.commandCenter.restartGateway}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={restart}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<icons.RefreshCw />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={copy.openSystem}>
|
||||
<Button
|
||||
aria-label={copy.openSystem}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={openSystem}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<icons.LayoutDashboard />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readiness?.reason && (
|
||||
<Section className="text-xs text-muted-foreground">
|
||||
<div className="line-clamp-3">{readiness.reason}</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
<Section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SectionLabel>{copy.recentActivity}</SectionLabel>
|
||||
<Button
|
||||
className="-mr-2 h-auto py-0 font-medium leading-none text-muted-foreground"
|
||||
onClick={openSystem}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{copy.viewAllLogs}
|
||||
</Button>
|
||||
</div>
|
||||
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
|
||||
{recentLogs.map(trimLogLine).join('\n')}
|
||||
</LogView>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<Section>
|
||||
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{platforms.map(([name, platform]) => (
|
||||
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
|
||||
<span className="truncate capitalize">{name}</span>
|
||||
<span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground">
|
||||
<StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} />
|
||||
{prettyState(platform.state)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: <PillIcon />,
|
||||
id: 'gateway-pill',
|
||||
label: <PillLabel />,
|
||||
detail: <PillDetail />,
|
||||
menuClassName: 'w-72',
|
||||
menuContent: (close: () => void) => <GatewayMenuPanel onClose={close} />,
|
||||
variant: 'menu'
|
||||
} satisfies StatusbarItem
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
||||
38
apps/desktop/src/plugins/hello-runtime/plugin.runtime.js
Normal file
38
apps/desktop/src/plugins/hello-runtime/plugin.runtime.js
Normal file
|
|
@ -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/<name>/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, {})
|
||||
})
|
||||
}
|
||||
}
|
||||
253
apps/desktop/src/plugins/kanban/api.ts
Normal file
253
apps/desktop/src/plugins/kanban/api.ts
Normal file
|
|
@ -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=<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 {
|
||||
BoardMeta,
|
||||
BoardsResponse,
|
||||
KanbanBoard,
|
||||
KanbanProfile,
|
||||
KanbanProject,
|
||||
KanbanTask,
|
||||
KanbanTaskDetail,
|
||||
OrchestrationSettings,
|
||||
TaskEstimate,
|
||||
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!) })
|
||||
}
|
||||
}
|
||||
|
||||
// A persisted, subscribable atom (the structural slice we need — avoids
|
||||
// importing nanostore's type just to describe one).
|
||||
interface Persisted<T> {
|
||||
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 = <T>(atom: Persisted<T>, 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<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 PROJECTS_KEY = ['kanban', 'projects'] 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')
|
||||
|
||||
/** First-class Hermes projects, for scoping a board's default workspace. */
|
||||
export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects')
|
||||
|
||||
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, 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<TaskEstimate>(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<TaskEstimate>('/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<string, unknown>) =>
|
||||
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<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 } }
|
||||
)
|
||||
243
apps/desktop/src/plugins/kanban/board-switcher.tsx
Normal file
243
apps/desktop/src/plugins/kanban/board-switcher.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* 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,
|
||||
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 (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className={FIELD_LABEL}>{k.project}</span>
|
||||
<Select onValueChange={id => onChange(id === NO_PROJECT ? '' : id)} value={value || NO_PROJECT}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PROJECT}>{k.noProject}</SelectItem>
|
||||
{projects.map(project => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">
|
||||
{k.projectHintPre}
|
||||
<span className="font-mono">{k.projectHintCmd}</span>.
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog onOpenChange={o => !o && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{k.newBoard}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className={FIELD_LABEL}>{k.name}</span>
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => event.key === 'Enter' && slug && !project && create.mutate()}
|
||||
placeholder={k.boardNamePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.slug(slug)}</span>}
|
||||
</label>
|
||||
<ProjectPicker onChange={setProject} value={project} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="text">
|
||||
{k.cancel}
|
||||
</Button>
|
||||
<Button disabled={!slug || create.isPending} onClick={() => create.mutate()}>
|
||||
{k.createBoard}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog onOpenChange={o => !o && onClose()} open={Boolean(board)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{board ? k.boardSettingsFor(board.name || board.slug) : k.boardSettings}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className={FIELD_LABEL}>{k.name}</span>
|
||||
<Input onChange={event => setName(event.target.value)} placeholder={k.boardNamePlaceholder} value={name} />
|
||||
{board && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.slug(board.slug)}</span>}
|
||||
</label>
|
||||
<ProjectPicker onChange={setProject} value={project} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="text">
|
||||
{k.cancel}
|
||||
</Button>
|
||||
<Button disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{k.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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<BoardMeta | null>(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 (
|
||||
<>
|
||||
<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 />
|
||||
{current && (
|
||||
<DropdownMenuItem onSelect={() => setSettingsFor(current)}>
|
||||
<Codicon name="settings-gear" size="0.8rem" />
|
||||
{k.boardSettings}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => setAdding(true)}>
|
||||
<Codicon name="add" size="0.8rem" />
|
||||
{k.newBoardDots}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<NewBoardDialog onClose={() => setAdding(false)} open={adding} />
|
||||
<BoardSettingsDialog board={settingsFor} onClose={() => setSettingsFor(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
1411
apps/desktop/src/plugins/kanban/board.tsx
Normal file
1411
apps/desktop/src/plugins/kanban/board.tsx
Normal file
File diff suppressed because it is too large
Load diff
945
apps/desktop/src/plugins/kanban/drawer.tsx
Normal file
945
apps/desktop/src/plugins/kanban/drawer.tsx
Normal file
|
|
@ -0,0 +1,945 @@
|
|||
/**
|
||||
* 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,
|
||||
compactNumber,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
ErrorState,
|
||||
host,
|
||||
Loader,
|
||||
LogView,
|
||||
Textarea,
|
||||
Tip,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
$boardSlug,
|
||||
addComment,
|
||||
deleteTask,
|
||||
estimateTask,
|
||||
fetchLog,
|
||||
fetchProfiles,
|
||||
fetchTask,
|
||||
logKey,
|
||||
patchTask,
|
||||
PROFILES_KEY,
|
||||
reassignTask,
|
||||
reclaimTask,
|
||||
taskKey,
|
||||
uploadAttachment
|
||||
} from './api'
|
||||
import {
|
||||
type Diagnostic,
|
||||
type DiagnosticAction,
|
||||
type KanbanAttachment,
|
||||
type KanbanEvent,
|
||||
type KanbanTaskDetail,
|
||||
SEVERITY_TONE,
|
||||
type TaskEstimate
|
||||
} from './types'
|
||||
import {
|
||||
ago,
|
||||
Avatar,
|
||||
Callout,
|
||||
columnLabel,
|
||||
duration,
|
||||
errText,
|
||||
isLockedTarget,
|
||||
type KanbanText,
|
||||
lockedReason,
|
||||
ScrollFade,
|
||||
Section,
|
||||
shortId,
|
||||
StatusMenu,
|
||||
useDefaultAssignee,
|
||||
useKanban
|
||||
} 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, k: KanbanText): { 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 ? columnLabel(k, value) : null
|
||||
}
|
||||
|
||||
switch (event.kind) {
|
||||
case 'created':
|
||||
return { label: k.evtCreated(col('status') ?? '', str('assignee') ?? '') }
|
||||
case 'status': {
|
||||
const reason = str('reason')
|
||||
|
||||
return {
|
||||
label: k.evtMovedTo(col('status') ?? '?'),
|
||||
detail: reason === 'parent_reopened' ? k.evtParentReopened(str('parent') ?? '') : (reason ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
case 'assigned': {
|
||||
const assignee = str('assignee')
|
||||
|
||||
return { label: assignee ? k.evtAssignedTo(assignee) : k.evtUnassigned }
|
||||
}
|
||||
|
||||
case 'commented':
|
||||
return { label: k.evtCommentBy(str('author') ?? k.someone) }
|
||||
|
||||
case 'claimed':
|
||||
return { label: str('source_status') === 'review' ? k.evtClaimedReview : k.evtClaimedWorker }
|
||||
|
||||
case 'spawned':
|
||||
return { label: k.evtWorkerStarted, detail: p.pid != null ? `pid ${p.pid}` : undefined }
|
||||
|
||||
case 'completed':
|
||||
return { label: k.evtCompleted }
|
||||
|
||||
case 'blocked':
|
||||
return { label: k.evtBlocked, detail: str('reason') ?? undefined }
|
||||
|
||||
case 'unblocked':
|
||||
return { label: k.evtUnblocked(col('status') ?? '') }
|
||||
|
||||
case 'reclaimed':
|
||||
return { label: k.evtReclaimed, detail: str('reason') ?? undefined }
|
||||
|
||||
case 'specified':
|
||||
return { label: k.evtSpecified }
|
||||
|
||||
case 'promoted':
|
||||
return { label: k.evtPromoted }
|
||||
|
||||
case 'scheduled':
|
||||
return { label: k.evtScheduled, detail: str('reason') ?? undefined }
|
||||
|
||||
case 'archived':
|
||||
return { label: k.evtArchived }
|
||||
|
||||
case 'reprioritized':
|
||||
return { label: k.evtReprioritized(String(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 k = useKanban()
|
||||
|
||||
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: k.commandCopied })
|
||||
}
|
||||
}
|
||||
|
||||
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 k = useKanban()
|
||||
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)">{k.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.
|
||||
//
|
||||
// On a RUNNING task the worker polls its comment thread and folds new notes
|
||||
// into the live turn (OUT-OF-BAND steer), so a plain note reaches the agent
|
||||
// mid-run within a few seconds — no block/unblock dance. `onRequeue` is the
|
||||
// heavier option: post the note AND reclaim so the task restarts from scratch
|
||||
// with the note in context (use when the current run has gone off the rails).
|
||||
function CommentComposer({
|
||||
onRequeue,
|
||||
onSubmit,
|
||||
pending,
|
||||
running
|
||||
}: {
|
||||
onRequeue?: (body: string) => void
|
||||
onSubmit: (body: string) => void
|
||||
pending: boolean
|
||||
running?: boolean
|
||||
}) {
|
||||
const k = useKanban()
|
||||
const [body, setBody] = useState('')
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = body.trim()
|
||||
|
||||
if (trimmed && !pending) {
|
||||
onSubmit(trimmed)
|
||||
setBody('')
|
||||
}
|
||||
}
|
||||
|
||||
const requeue = () => {
|
||||
const trimmed = body.trim()
|
||||
|
||||
if (trimmed && !pending && onRequeue) {
|
||||
onRequeue(trimmed)
|
||||
setBody('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className={cn('field-sizing-content max-h-40 min-h-0 resize-none', running ? 'pr-[3.5rem]' : 'pr-[5rem]')}
|
||||
onChange={event => setBody(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
placeholder={running ? k.messageWorker : k.addComment}
|
||||
rows={1}
|
||||
size="sm"
|
||||
value={body}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-1 right-1"
|
||||
disabled={!body.trim() || pending}
|
||||
onClick={submit}
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
>
|
||||
{running ? k.send : k.comment}
|
||||
</Button>
|
||||
</div>
|
||||
{running && onRequeue && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.625rem] leading-tight text-(--ui-text-quaternary)">{k.deliveredLive}</span>
|
||||
<Button className="shrink-0" disabled={!body.trim() || pending} onClick={requeue} size="xs" variant="outline">
|
||||
<Codicon name="debug-restart" size="0.7rem" />
|
||||
{k.requeueWithNote}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DescriptionSection({ body, onSave }: { body: null | string | undefined; onSave: (body: string) => void }) {
|
||||
const k = useKanban()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
return (
|
||||
<Section
|
||||
action={
|
||||
<Button
|
||||
aria-label={editing ? k.cancelEdit : k.editDescription}
|
||||
onClick={() => {
|
||||
setDraft(body ?? '')
|
||||
setEditing(!editing)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={editing ? 'close' : 'edit'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label={k.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"
|
||||
>
|
||||
{k.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)">{k.noDescription}</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 k = useKanban()
|
||||
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={k.uploadAttachment}
|
||||
disabled={pending}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={pending ? 'sync' : 'cloud-upload'} size="0.8rem" spinning={pending} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
label={k.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)">{k.noAttachments}</p>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// Rough effort estimate via the auxiliary (auto-routed) model. Tokens +
|
||||
// complexity, never dollars — providers don't report cost reliably. Gated
|
||||
// behind an explicit click + disclaimer since it makes a model call. The
|
||||
// control keeps a stable footprint (spinner swaps in place) so there's no
|
||||
// layout jump when it runs.
|
||||
function EstimateSection({ id }: { id: string }) {
|
||||
const k = useKanban()
|
||||
const [result, setResult] = useState<null | TaskEstimate>(null)
|
||||
|
||||
const est = useMutation({
|
||||
mutationFn: () => estimateTask(id),
|
||||
onError: err => host.notify({ kind: 'error', message: errText(err) }),
|
||||
onSuccess: r => {
|
||||
if (r.ok) {
|
||||
setResult(r)
|
||||
} else {
|
||||
host.notify({ kind: 'warning', message: r.reason || k.couldNotEstimate })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A new task resets the cached estimate (the drawer reuses one instance).
|
||||
useEffect(() => setResult(null), [id])
|
||||
|
||||
return (
|
||||
<Section label={k.estimate}>
|
||||
{result?.ok ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-[0.8125rem]">
|
||||
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
|
||||
~{compactNumber(result.est_tokens)} {k.tokUnit}
|
||||
</span>
|
||||
{result.complexity && (
|
||||
<span className="text-(--ui-text-tertiary)">· {k.complexity[result.complexity] ?? result.complexity}</span>
|
||||
)}
|
||||
<Tip label={k.reEstimate}>
|
||||
<Button
|
||||
aria-label={k.reEstimate}
|
||||
className="ml-auto"
|
||||
disabled={est.isPending}
|
||||
onClick={() => est.mutate()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.75rem" spinning={est.isPending} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
{result.rationale && (
|
||||
<p className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">{result.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={est.isPending} onClick={() => est.mutate()} size="xs" variant="outline">
|
||||
<Codicon name={est.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={est.isPending} />
|
||||
{est.isPending ? k.estimating : k.estimateEffort}
|
||||
</Button>
|
||||
<Tip label={k.estimateTipLong}>
|
||||
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{k.makesModelCall}</span>
|
||||
</Tip>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskDrawer({
|
||||
columns,
|
||||
id,
|
||||
onClose,
|
||||
onOpen
|
||||
}: {
|
||||
columns: string[]
|
||||
id: null | string
|
||||
onClose: () => void
|
||||
onOpen: (id: string) => void
|
||||
}) {
|
||||
const k = useKanban()
|
||||
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
|
||||
})
|
||||
|
||||
// "Note & requeue" for a running task: post the note, then reclaim so the
|
||||
// dispatcher re-runs it with the note in the worker's context — the one-click
|
||||
// replacement for the block → comment → unblock dance.
|
||||
const requeueMut = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
await addComment(id!, body)
|
||||
await reclaimTask(id!)
|
||||
},
|
||||
onError: err => host.notify({ kind: 'error', message: errText(err) }),
|
||||
onSuccess: () => {
|
||||
host.notify({ kind: 'info', message: k.notePosted })
|
||||
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: lockedReason(k, 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={k.taskActions}
|
||||
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: k.copiedId(task.id) })
|
||||
}}
|
||||
>
|
||||
<Codicon name="copy" size="0.85rem" />
|
||||
{k.copyTaskId}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigator.clipboard.writeText(task.title || task.id)
|
||||
host.notify({ kind: 'info', message: k.copiedTitle })
|
||||
}}
|
||||
>
|
||||
<Codicon name="copy" size="0.85rem" />
|
||||
{k.copyTitle}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
|
||||
<Codicon name="archive" size="0.85rem" />
|
||||
{k.archiveTask}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onSelect={mutate(() => deleteTask(task.id), onClose)}>
|
||||
<Codicon name="trash" size="0.85rem" />
|
||||
{k.deleteTask}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<button
|
||||
aria-label={k.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={k.assignee}>
|
||||
<AssigneeMenu
|
||||
current={task.assignee}
|
||||
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
|
||||
/>
|
||||
</MetaRow>
|
||||
{typeof task.priority === 'number' && <MetaRow label={k.metaPriority}>{task.priority}</MetaRow>}
|
||||
{task.tenant && <MetaRow label={k.metaTenant}>{task.tenant}</MetaRow>}
|
||||
{task.workspace_path && (
|
||||
<MetaRow label={k.workspace}>
|
||||
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
|
||||
{task.workspace_path}
|
||||
</MetaRow>
|
||||
)}
|
||||
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
|
||||
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
|
||||
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}
|
||||
</div>
|
||||
|
||||
{task.status === 'ready' && !task.assignee && !defaultAssignee && (
|
||||
<Callout title={k.readyUnassignedTitle} tone={SEVERITY_TONE.warning}>
|
||||
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">{k.readyUnassignedBody}</p>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{task.diagnostics && task.diagnostics.length > 0 && (
|
||||
<Section label={k.diagnosticsN(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 }))()} />
|
||||
|
||||
<EstimateSection id={task.id} />
|
||||
|
||||
{task.result && (
|
||||
<Section label={k.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={k.latestSummary}>
|
||||
<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={k.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' ? k.blockedBy : k.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
|
||||
action={
|
||||
<Tip label={running ? k.commentsHelpRunning : k.commentsHelp}>
|
||||
<span className="grid size-5 place-items-center rounded text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)">
|
||||
<Codicon name="question" size="0.8rem" />
|
||||
</span>
|
||||
</Tip>
|
||||
}
|
||||
label={k.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
|
||||
onRequeue={body => requeueMut.mutate(body)}
|
||||
onSubmit={body => commentMut.mutate(body)}
|
||||
pending={commentMut.isPending || requeueMut.isPending}
|
||||
running={running}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{detail.events.length > 0 && (
|
||||
<Section label={k.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, k)
|
||||
|
||||
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={k.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={log.truncated ? k.workerLogTail : k.workerLog}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
970
apps/desktop/src/plugins/kanban/i18n.ts
Normal file
970
apps/desktop/src/plugins/kanban/i18n.ts
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
/**
|
||||
* Plugin-scoped i18n for kanban — bundles shipped under the plugin id via
|
||||
* ctx.i18n.register (#67303), never touching core en.ts. usePluginI18n('kanban')
|
||||
* returns a stringly-typed t(key, …); `useKanban()` binds it to the message
|
||||
* SHAPE so components keep typed `k.newTask` / `k.moveTo(label)` access.
|
||||
*/
|
||||
|
||||
import { type PluginLocaleBundles, type PluginTranslate, usePluginI18n } from '@hermes/plugin-sdk'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type KanbanMessages = {
|
||||
nav: string
|
||||
openBoard: string
|
||||
/** Command label — shows in the ⌘K palette AND as the keybind panel row,
|
||||
* so it carries the "Kanban: " prefix the palette convention wants. */
|
||||
newTaskCommand: string
|
||||
countTip: (running: number, ready: number) => string
|
||||
col: Record<
|
||||
'archived' | 'blocked' | 'done' | 'ready' | 'review' | 'running' | 'scheduled' | 'todo' | 'triage',
|
||||
{ label: string; help: string }
|
||||
>
|
||||
locked: { review: string; running: string; scheduled: string }
|
||||
arcRunning: string
|
||||
arcStale: string
|
||||
title: string
|
||||
orchestrationSettings: string
|
||||
newTask: string
|
||||
filterCards: string
|
||||
noMatch: string
|
||||
noTasks: string
|
||||
open: string
|
||||
select: string
|
||||
deselect: string
|
||||
moveTo: (label: string) => string
|
||||
delete: string
|
||||
reviewChecking: string
|
||||
attachedTip: (name: string) => string
|
||||
orchestratorTip: (name: string) => string
|
||||
autoAssignTip: (name: string) => string
|
||||
wontRun: string
|
||||
wontRunTip: string
|
||||
noHeartbeat: string
|
||||
expand: (label: string) => string
|
||||
collapse: (label: string) => string
|
||||
newTaskIn: (label: string) => string
|
||||
empty: string
|
||||
unassigned: string
|
||||
filters: string
|
||||
allProfiles: string
|
||||
allTenants: string
|
||||
showArchived: string
|
||||
groupRunning: string
|
||||
nSelected: (n: number) => string
|
||||
moveToShort: string
|
||||
assign: string
|
||||
unassignAction: string
|
||||
archive: string
|
||||
clearSelection: string
|
||||
refused: string
|
||||
bulkFailed: (failed: number, total: number, err: string) => string
|
||||
titlePlaceholderTriage: string
|
||||
titlePlaceholder: string
|
||||
descPlaceholder: string
|
||||
priority: string
|
||||
workspace: string
|
||||
boardDefaultSuffix: string
|
||||
workspaceOverride: string
|
||||
workspaceInherit: string
|
||||
workspaceInheritDir: (dir: string) => string
|
||||
workspaceInheritGeneric: string
|
||||
assignee: string
|
||||
defaultOption: (name: string) => string
|
||||
parkedOption: string
|
||||
skills: string
|
||||
skillsPlaceholder: string
|
||||
parent: string
|
||||
noParent: string
|
||||
goalMode: string
|
||||
creating: string
|
||||
createTask: string
|
||||
cancel: string
|
||||
save: string
|
||||
estimate: string
|
||||
estimateEffort: string
|
||||
estimating: string
|
||||
reEstimate: string
|
||||
makesModelCall: string
|
||||
estimateTip: string
|
||||
estimateTipLong: string
|
||||
roughEstimate: string
|
||||
tokUnit: string
|
||||
couldNotEstimate: string
|
||||
complexity: Record<'L' | 'M' | 'S', string>
|
||||
introBody: string
|
||||
introGotIt: string
|
||||
// drawer — activity prose
|
||||
evtCreated: (where: string, assignee: string) => string
|
||||
evtMovedTo: (col: string) => string
|
||||
evtParentReopened: (parent: string) => string
|
||||
evtAssignedTo: (assignee: string) => string
|
||||
evtUnassigned: string
|
||||
evtCommentBy: (author: string) => string
|
||||
evtClaimedReview: string
|
||||
evtClaimedWorker: string
|
||||
evtWorkerStarted: string
|
||||
evtCompleted: string
|
||||
evtBlocked: string
|
||||
evtUnblocked: (col: string) => string
|
||||
evtReclaimed: string
|
||||
evtSpecified: string
|
||||
evtPromoted: string
|
||||
evtScheduled: string
|
||||
evtArchived: string
|
||||
evtReprioritized: (priority: string) => string
|
||||
someone: string
|
||||
// drawer — meta + sections
|
||||
metaPriority: string
|
||||
metaTenant: string
|
||||
metaCreatedBy: string
|
||||
metaCreated: string
|
||||
metaWorkerPid: string
|
||||
readyUnassignedTitle: string
|
||||
readyUnassignedBody: string
|
||||
diagnosticsN: (n: number) => string
|
||||
commandCopied: string
|
||||
description: string
|
||||
editDescription: string
|
||||
cancelEdit: string
|
||||
noDescription: string
|
||||
result: string
|
||||
latestSummary: string
|
||||
dependencies: string
|
||||
blockedBy: string
|
||||
blocks: string
|
||||
comments: (n: number) => string
|
||||
commentsHelpRunning: string
|
||||
commentsHelp: string
|
||||
send: string
|
||||
comment: string
|
||||
messageWorker: string
|
||||
addComment: string
|
||||
deliveredLive: string
|
||||
requeueWithNote: string
|
||||
notePosted: string
|
||||
activity: (n: number) => string
|
||||
runs: (n: number) => string
|
||||
workerLog: string
|
||||
workerLogTail: string
|
||||
attachments: (n: number) => string
|
||||
noAttachments: string
|
||||
uploadAttachment: string
|
||||
taskActions: string
|
||||
copyTaskId: string
|
||||
copyTitle: string
|
||||
copiedId: (id: string) => string
|
||||
copiedTitle: string
|
||||
archiveTask: string
|
||||
deleteTask: string
|
||||
close: string
|
||||
working: string
|
||||
// board switcher
|
||||
board: string
|
||||
newBoard: string
|
||||
newBoardDots: string
|
||||
boardSettings: string
|
||||
boardSettingsFor: (name: string) => string
|
||||
name: string
|
||||
boardNamePlaceholder: string
|
||||
slug: (slug: string) => string
|
||||
project: string
|
||||
noProject: string
|
||||
projectHintPre: string
|
||||
projectHintCmd: string
|
||||
createBoard: string
|
||||
// orchestration
|
||||
orchestratorProfile: string
|
||||
defaultAssignee: string
|
||||
defaultParen: string
|
||||
autoDecompose: string
|
||||
profileDescriptions: string
|
||||
profileDescriptionsHint: string
|
||||
profileGoodAt: string
|
||||
auto: string
|
||||
}
|
||||
|
||||
const en: KanbanMessages = {
|
||||
nav: 'Kanban',
|
||||
openBoard: 'Kanban: Open board',
|
||||
newTaskCommand: 'Kanban: New task',
|
||||
countTip: (running, ready) => `Kanban — ${running} running, ${ready} ready`,
|
||||
col: {
|
||||
triage: { label: 'Triage', help: 'Raw ideas — a specifier fleshes out the spec.' },
|
||||
todo: { label: 'Todo', help: 'Waiting on dependencies, or unassigned.' },
|
||||
scheduled: { label: 'Scheduled', help: 'Waiting for a scheduled time to arrive.' },
|
||||
ready: { label: 'Ready', help: 'Dependencies satisfied — assign a profile and the dispatcher runs it.' },
|
||||
running: { label: 'Running', help: 'Claimed by a worker — an agent is on it. Set by the dispatcher.' },
|
||||
blocked: { label: 'Blocked', help: 'The worker asked for human input.' },
|
||||
review: { label: 'Review', help: 'A review agent is checking the work. Set by the dispatcher.' },
|
||||
done: { label: 'Done', help: 'Completed; dependent children become ready.' },
|
||||
archived: { label: 'Archived', help: 'Hidden from the default board view.' }
|
||||
},
|
||||
locked: {
|
||||
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.'
|
||||
},
|
||||
arcRunning: 'An agent is working on this now.',
|
||||
arcStale: 'Claimed, but no worker heartbeat for 2+ minutes — the dispatcher will reclaim it.',
|
||||
title: 'Kanban',
|
||||
orchestrationSettings: 'Orchestration settings',
|
||||
newTask: 'New task',
|
||||
filterCards: 'Filter cards…',
|
||||
noMatch: 'No tasks match the filters',
|
||||
noTasks: 'No tasks on this board',
|
||||
open: 'Open',
|
||||
select: 'Select (⌘-click)',
|
||||
deselect: 'Deselect',
|
||||
moveTo: label => `Move to ${label}`,
|
||||
delete: 'Delete',
|
||||
reviewChecking: 'A review agent is checking the completed work.',
|
||||
attachedTip: name => `${name} is attached — the dispatcher hands this over on its next tick (≤1m).`,
|
||||
orchestratorTip: name => `${name} (the orchestrator) picks this up on the next tick and writes the spec.`,
|
||||
autoAssignTip: name => `Auto-assigns to “${name}” (kanban.default_assignee) on the next dispatch tick.`,
|
||||
wontRun: "won't run",
|
||||
wontRunTip:
|
||||
'Ready cards only run once a profile is assigned. Open the card and set an assignee, or configure a default assignee in orchestration settings.',
|
||||
noHeartbeat: 'no heartbeat',
|
||||
expand: label => `Expand ${label}`,
|
||||
collapse: label => `Collapse ${label}`,
|
||||
newTaskIn: label => `New task in ${label}`,
|
||||
empty: 'Empty',
|
||||
unassigned: 'unassigned',
|
||||
filters: 'Filters',
|
||||
allProfiles: 'All profiles',
|
||||
allTenants: 'All tenants',
|
||||
showArchived: 'Show archived',
|
||||
groupRunning: 'Group Running by profile',
|
||||
nSelected: n => `${n} selected`,
|
||||
moveToShort: 'Move to',
|
||||
assign: 'Assign',
|
||||
unassignAction: 'Unassign',
|
||||
archive: 'Archive',
|
||||
clearSelection: 'Clear selection (Esc)',
|
||||
refused: 'refused',
|
||||
bulkFailed: (failed, total, err) => `${failed} of ${total} failed — ${err}. Failed cards stay selected.`,
|
||||
titlePlaceholderTriage: 'Rough idea — a specifier will flesh it out',
|
||||
titlePlaceholder: 'Title',
|
||||
descPlaceholder: 'Description (optional)',
|
||||
priority: 'Priority',
|
||||
workspace: 'Workspace',
|
||||
boardDefaultSuffix: ' · board default',
|
||||
workspaceOverride: 'Workspace path (optional override)',
|
||||
workspaceInherit: 'Inherits the board’s project directory',
|
||||
workspaceInheritDir: dir => `Leave empty to inherit ${dir}`,
|
||||
workspaceInheritGeneric: 'Leave empty to inherit the board’s project directory.',
|
||||
assignee: 'Assignee',
|
||||
defaultOption: name => `${name} (default)`,
|
||||
parkedOption: "unassigned (parked — won't run)",
|
||||
skills: 'Skills (comma-separated)',
|
||||
skillsPlaceholder: 'translation, github',
|
||||
parent: "Parent (blocks until it's done)",
|
||||
noParent: '— no parent —',
|
||||
goalMode: "Goal mode (worker loops until a judge agrees it's done)",
|
||||
creating: 'Creating…',
|
||||
createTask: 'Create task',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
estimate: 'Estimate',
|
||||
estimateEffort: 'Estimate effort',
|
||||
estimating: 'Estimating…',
|
||||
reEstimate: 'Re-estimate',
|
||||
makesModelCall: 'makes a model call',
|
||||
estimateTip: 'Rough token + complexity estimate from the auxiliary model — makes a model call.',
|
||||
estimateTipLong: 'Runs a quick auxiliary-model call to estimate tokens + complexity. A rough guide, not a bill.',
|
||||
roughEstimate: 'Rough estimate',
|
||||
tokUnit: 'tok',
|
||||
couldNotEstimate: 'Could not estimate',
|
||||
complexity: { S: 'Small', M: 'Medium', L: 'Large' },
|
||||
introBody:
|
||||
'You don’t run the cards — agents do. Put a card in Ready with an assignee and an agent picks it up within a minute. No assignee, no run. Triage: an agent rewrites the idea into a proper task first. Todo: waiting on other cards. Scheduled: waiting on a timer. Running and Review: the agents’ lanes, hands off. Blocked: it’s waiting on you. Results come back on the card.',
|
||||
introGotIt: 'Got it',
|
||||
evtCreated: (where, assignee) => `created${where ? ` in ${where}` : ''}${assignee ? ` · assigned to ${assignee}` : ''}`,
|
||||
evtMovedTo: col => `moved to ${col}`,
|
||||
evtParentReopened: parent => `parent ${parent} reopened`,
|
||||
evtAssignedTo: assignee => `assigned to ${assignee}`,
|
||||
evtUnassigned: 'unassigned',
|
||||
evtCommentBy: author => `comment by ${author}`,
|
||||
evtClaimedReview: 'claimed by a review agent',
|
||||
evtClaimedWorker: 'claimed by a worker',
|
||||
evtWorkerStarted: 'worker started',
|
||||
evtCompleted: 'completed',
|
||||
evtBlocked: 'blocked — needs human input',
|
||||
evtUnblocked: col => `unblocked${col ? ` → ${col}` : ' → Ready'}`,
|
||||
evtReclaimed: 'reclaimed — returned to the queue',
|
||||
evtSpecified: 'spec written by the triage agent',
|
||||
evtPromoted: 'dependencies done — promoted to Ready',
|
||||
evtScheduled: 'scheduled for later',
|
||||
evtArchived: 'archived',
|
||||
evtReprioritized: priority => `priority set to ${priority}`,
|
||||
someone: 'someone',
|
||||
metaPriority: 'Priority',
|
||||
metaTenant: 'Tenant',
|
||||
metaCreatedBy: 'Created by',
|
||||
metaCreated: 'Created',
|
||||
metaWorkerPid: 'Worker pid',
|
||||
readyUnassignedTitle: 'Ready, but unassigned — this card will never run.',
|
||||
readyUnassignedBody:
|
||||
'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.',
|
||||
diagnosticsN: n => `Diagnostics · ${n}`,
|
||||
commandCopied: 'Command copied',
|
||||
description: 'Description',
|
||||
editDescription: 'Edit description',
|
||||
cancelEdit: 'Cancel edit',
|
||||
noDescription: 'No description yet.',
|
||||
result: 'Result',
|
||||
latestSummary: 'Latest summary',
|
||||
dependencies: 'Dependencies',
|
||||
blockedBy: 'Blocked by',
|
||||
blocks: 'Blocks',
|
||||
comments: n => `Comments · ${n}`,
|
||||
commentsHelpRunning:
|
||||
'This task is running. Your note is folded into the worker’s current turn within a few seconds — no block/unblock dance. “Requeue with note” instead restarts the task from scratch with your note in context.',
|
||||
commentsHelp:
|
||||
'Comments are added to the task thread. When a worker picks the task up it reads them as part of its context.',
|
||||
send: 'Send',
|
||||
comment: 'Comment',
|
||||
messageWorker: 'Message the running worker…',
|
||||
addComment: 'Add a comment…',
|
||||
deliveredLive: 'Delivered to the running worker within a few seconds.',
|
||||
requeueWithNote: 'Requeue with note',
|
||||
notePosted: 'Note posted — worker requeued',
|
||||
activity: n => `Activity · ${n}`,
|
||||
runs: n => `Runs · ${n}`,
|
||||
workerLog: 'Worker log',
|
||||
workerLogTail: 'Worker log · tail',
|
||||
attachments: n => `Attachments · ${n}`,
|
||||
noAttachments: 'No attachments yet.',
|
||||
uploadAttachment: 'Upload attachment',
|
||||
taskActions: 'Task actions',
|
||||
copyTaskId: 'Copy task id',
|
||||
copyTitle: 'Copy title',
|
||||
copiedId: id => `Copied ${id}`,
|
||||
copiedTitle: 'Copied title',
|
||||
archiveTask: 'Archive task',
|
||||
deleteTask: 'Delete task',
|
||||
close: 'Close',
|
||||
working: 'working',
|
||||
board: 'Board',
|
||||
newBoard: 'New board',
|
||||
newBoardDots: 'New board…',
|
||||
boardSettings: 'Board settings…',
|
||||
boardSettingsFor: name => `Board settings — ${name}`,
|
||||
name: 'Name',
|
||||
boardNamePlaceholder: 'Board name',
|
||||
slug: slug => `slug: ${slug}`,
|
||||
project: 'Project',
|
||||
noProject: 'No project (scratch sandboxes)',
|
||||
projectHintPre:
|
||||
'New tasks run in the project’s repo (a worktree per task); each task can still override its workspace at creation. Manage projects with ',
|
||||
projectHintCmd: 'hermes project',
|
||||
createBoard: 'Create board',
|
||||
orchestratorProfile: 'Orchestrator profile',
|
||||
defaultAssignee: 'Default assignee',
|
||||
defaultParen: '(default)',
|
||||
autoDecompose: 'Auto-decompose triage tasks',
|
||||
profileDescriptions: 'Profile descriptions',
|
||||
profileDescriptionsHint:
|
||||
'Descriptions guide the decomposer’s routing. Auto-generate with the auxiliary model, or write your own.',
|
||||
profileGoodAt: 'What is this profile good at?',
|
||||
auto: 'Auto'
|
||||
}
|
||||
|
||||
const ja: KanbanMessages = {
|
||||
nav: 'カンバン',
|
||||
openBoard: 'カンバン: ボードを開く',
|
||||
newTaskCommand: 'カンバン: 新しいタスク',
|
||||
countTip: (running, ready) => `カンバン — 実行中 ${running}、待機 ${ready}`,
|
||||
col: {
|
||||
triage: { label: 'トリアージ', help: '生のアイデア — スペシファイアが仕様に整えます。' },
|
||||
todo: { label: 'Todo', help: '依存関係の待ち、または未割り当て。' },
|
||||
scheduled: { label: 'スケジュール', help: '予定時刻を待っています。' },
|
||||
ready: { label: 'Ready', help: '依存関係が解決済み — プロフィールを割り当てるとディスパッチャが実行します。' },
|
||||
running: { label: '実行中', help: 'ワーカーが取得済み — エージェントが作業中。ディスパッチャが設定します。' },
|
||||
blocked: { label: 'ブロック', help: 'ワーカーが人間の入力を求めています。' },
|
||||
review: { label: 'レビュー', help: 'レビューエージェントが作業を確認中。ディスパッチャが設定します。' },
|
||||
done: { label: '完了', help: '完了。依存する子タスクが Ready になります。' },
|
||||
archived: { label: 'アーカイブ', help: 'デフォルトのボード表示から非表示。' }
|
||||
},
|
||||
locked: {
|
||||
review: 'レビューは、レビューエージェントがカードを取得するとディスパッチャによって設定されます。',
|
||||
running: '実行中は、ワーカーがカードを取得するとディスパッチャによって設定されます。',
|
||||
scheduled: 'スケジュールには起動時刻が必要です — エージェントが設定します。ドラッグでは移動できません。'
|
||||
},
|
||||
arcRunning: 'エージェントが現在作業中です。',
|
||||
arcStale: '取得済みですが、2分以上ワーカーのハートビートがありません — ディスパッチャが再取得します。',
|
||||
title: 'カンバン',
|
||||
orchestrationSettings: 'オーケストレーション設定',
|
||||
newTask: '新しいタスク',
|
||||
filterCards: 'カードを絞り込み…',
|
||||
noMatch: 'フィルタに一致するタスクはありません',
|
||||
noTasks: 'このボードにタスクはありません',
|
||||
open: '開く',
|
||||
select: '選択(⌘クリック)',
|
||||
deselect: '選択解除',
|
||||
moveTo: label => `${label} へ移動`,
|
||||
delete: '削除',
|
||||
reviewChecking: 'レビューエージェントが完了した作業を確認中です。',
|
||||
attachedTip: name => `${name} が担当 — ディスパッチャが次のティック(≤1分)で引き渡します。`,
|
||||
orchestratorTip: name => `${name}(オーケストレーター)が次のティックでこれを取得し、仕様を書きます。`,
|
||||
autoAssignTip: name => `次のディスパッチティックで「${name}」(kanban.default_assignee)に自動割り当てされます。`,
|
||||
wontRun: '実行されません',
|
||||
wontRunTip:
|
||||
'Ready のカードはプロフィールが割り当てられて初めて実行されます。カードを開いて担当を設定するか、オーケストレーション設定でデフォルトの担当を設定してください。',
|
||||
noHeartbeat: 'ハートビートなし',
|
||||
expand: label => `${label} を展開`,
|
||||
collapse: label => `${label} を折りたたむ`,
|
||||
newTaskIn: label => `${label} に新しいタスク`,
|
||||
empty: '空',
|
||||
unassigned: '未割り当て',
|
||||
filters: 'フィルタ',
|
||||
allProfiles: 'すべてのプロフィール',
|
||||
allTenants: 'すべてのテナント',
|
||||
showArchived: 'アーカイブを表示',
|
||||
groupRunning: '実行中をプロフィールでグループ化',
|
||||
nSelected: n => `${n} 件選択中`,
|
||||
moveToShort: '移動',
|
||||
assign: '割り当て',
|
||||
unassignAction: '割り当て解除',
|
||||
archive: 'アーカイブ',
|
||||
clearSelection: '選択をクリア(Esc)',
|
||||
refused: '拒否されました',
|
||||
bulkFailed: (failed, total, err) => `${total} 件中 ${failed} 件が失敗 — ${err}。失敗したカードは選択されたままです。`,
|
||||
titlePlaceholderTriage: '大まかなアイデア — スペシファイアが具体化します',
|
||||
titlePlaceholder: 'タイトル',
|
||||
descPlaceholder: '説明(任意)',
|
||||
priority: '優先度',
|
||||
workspace: 'ワークスペース',
|
||||
boardDefaultSuffix: '・ボード既定',
|
||||
workspaceOverride: 'ワークスペースパス(任意の上書き)',
|
||||
workspaceInherit: 'ボードのプロジェクトディレクトリを継承',
|
||||
workspaceInheritDir: dir => `空欄にすると ${dir} を継承します`,
|
||||
workspaceInheritGeneric: '空欄にするとボードのプロジェクトディレクトリを継承します。',
|
||||
assignee: '担当',
|
||||
defaultOption: name => `${name}(既定)`,
|
||||
parkedOption: '未割り当て(保留 — 実行されません)',
|
||||
skills: 'スキル(カンマ区切り)',
|
||||
skillsPlaceholder: 'translation, github',
|
||||
parent: '親(完了するまでブロック)',
|
||||
noParent: '— 親なし —',
|
||||
goalMode: 'ゴールモード(ジャッジが完了と認めるまでワーカーがループ)',
|
||||
creating: '作成中…',
|
||||
createTask: 'タスクを作成',
|
||||
cancel: 'キャンセル',
|
||||
save: '保存',
|
||||
estimate: '見積もり',
|
||||
estimateEffort: '工数を見積もり',
|
||||
estimating: '見積もり中…',
|
||||
reEstimate: '再見積もり',
|
||||
makesModelCall: 'モデル呼び出しあり',
|
||||
estimateTip: '補助モデルによるトークン数と複雑度の概算 — モデル呼び出しを行います。',
|
||||
estimateTipLong: '補助モデルを呼び出してトークン数と複雑度を概算します。目安であり、請求ではありません。',
|
||||
roughEstimate: '概算',
|
||||
tokUnit: 'tok',
|
||||
couldNotEstimate: '見積もりできませんでした',
|
||||
complexity: { S: '小', M: '中', L: '大' },
|
||||
introBody:
|
||||
'カードはあなたではなくエージェントが実行します。担当を設定したカードを Ready に置くと、1分以内にエージェントが取得します。担当がなければ実行されません。トリアージ: エージェントがまずアイデアを適切なタスクに書き直します。Todo: 他のカード待ち。スケジュール: タイマー待ち。実行中とレビュー: エージェントのレーンなので手を出さないでください。ブロック: あなたの対応待ちです。結果はカードに戻ってきます。',
|
||||
introGotIt: '了解',
|
||||
evtCreated: (where, assignee) =>
|
||||
`作成${where ? `(${where})` : ''}${assignee ? `・${assignee} に割り当て` : ''}`,
|
||||
evtMovedTo: col => `${col} へ移動`,
|
||||
evtParentReopened: parent => `親 ${parent} が再オープン`,
|
||||
evtAssignedTo: assignee => `${assignee} に割り当て`,
|
||||
evtUnassigned: '割り当て解除',
|
||||
evtCommentBy: author => `${author} のコメント`,
|
||||
evtClaimedReview: 'レビューエージェントが取得',
|
||||
evtClaimedWorker: 'ワーカーが取得',
|
||||
evtWorkerStarted: 'ワーカー開始',
|
||||
evtCompleted: '完了',
|
||||
evtBlocked: 'ブロック — 人間の入力が必要',
|
||||
evtUnblocked: col => `ブロック解除${col ? ` → ${col}` : ' → Ready'}`,
|
||||
evtReclaimed: '再取得 — キューに戻しました',
|
||||
evtSpecified: 'トリアージエージェントが仕様を作成',
|
||||
evtPromoted: '依存関係が完了 — Ready に昇格',
|
||||
evtScheduled: '後で実行するようスケジュール',
|
||||
evtArchived: 'アーカイブ済み',
|
||||
evtReprioritized: priority => `優先度を ${priority} に設定`,
|
||||
someone: '誰か',
|
||||
metaPriority: '優先度',
|
||||
metaTenant: 'テナント',
|
||||
metaCreatedBy: '作成者',
|
||||
metaCreated: '作成',
|
||||
metaWorkerPid: 'ワーカー PID',
|
||||
readyUnassignedTitle: 'Ready ですが未割り当て — このカードは実行されません。',
|
||||
readyUnassignedBody:
|
||||
'ディスパッチャは担当のある Ready カードのみ取得します。上の担当フィールドでプロフィールを選ぶ(またはオーケストレーション設定でデフォルトの担当を設定する)と、1分以内に実行されます。',
|
||||
diagnosticsN: n => `診断・${n}`,
|
||||
commandCopied: 'コマンドをコピーしました',
|
||||
description: '説明',
|
||||
editDescription: '説明を編集',
|
||||
cancelEdit: '編集をキャンセル',
|
||||
noDescription: 'まだ説明はありません。',
|
||||
result: '結果',
|
||||
latestSummary: '最新のサマリー',
|
||||
dependencies: '依存関係',
|
||||
blockedBy: 'ブロック元',
|
||||
blocks: 'ブロック先',
|
||||
comments: n => `コメント・${n}`,
|
||||
commentsHelpRunning:
|
||||
'このタスクは実行中です。あなたのメモは数秒以内にワーカーの現在のターンに取り込まれます — ブロック/解除の操作は不要です。「メモを付けて再キュー」を選ぶと、メモを文脈に含めてタスクを最初からやり直します。',
|
||||
commentsHelp: 'コメントはタスクのスレッドに追加されます。ワーカーがタスクを取得すると、文脈の一部として読み込みます。',
|
||||
send: '送信',
|
||||
comment: 'コメント',
|
||||
messageWorker: '実行中のワーカーにメッセージ…',
|
||||
addComment: 'コメントを追加…',
|
||||
deliveredLive: '数秒以内に実行中のワーカーへ届きます。',
|
||||
requeueWithNote: 'メモを付けて再キュー',
|
||||
notePosted: 'メモを投稿しました — ワーカーを再キューしました',
|
||||
activity: n => `アクティビティ・${n}`,
|
||||
runs: n => `実行・${n}`,
|
||||
workerLog: 'ワーカーログ',
|
||||
workerLogTail: 'ワーカーログ・末尾',
|
||||
attachments: n => `添付・${n}`,
|
||||
noAttachments: 'まだ添付はありません。',
|
||||
uploadAttachment: '添付をアップロード',
|
||||
taskActions: 'タスクの操作',
|
||||
copyTaskId: 'タスク ID をコピー',
|
||||
copyTitle: 'タイトルをコピー',
|
||||
copiedId: id => `${id} をコピーしました`,
|
||||
copiedTitle: 'タイトルをコピーしました',
|
||||
archiveTask: 'タスクをアーカイブ',
|
||||
deleteTask: 'タスクを削除',
|
||||
close: '閉じる',
|
||||
working: '作業中',
|
||||
board: 'ボード',
|
||||
newBoard: '新しいボード',
|
||||
newBoardDots: '新しいボード…',
|
||||
boardSettings: 'ボード設定…',
|
||||
boardSettingsFor: name => `ボード設定 — ${name}`,
|
||||
name: '名前',
|
||||
boardNamePlaceholder: 'ボード名',
|
||||
slug: slug => `slug: ${slug}`,
|
||||
project: 'プロジェクト',
|
||||
noProject: 'プロジェクトなし(スクラッチのサンドボックス)',
|
||||
projectHintPre:
|
||||
'新しいタスクはプロジェクトのリポジトリで実行されます(タスクごとに worktree)。各タスクは作成時にワークスペースを上書きできます。プロジェクトの管理は ',
|
||||
projectHintCmd: 'hermes project',
|
||||
createBoard: 'ボードを作成',
|
||||
orchestratorProfile: 'オーケストレータープロフィール',
|
||||
defaultAssignee: 'デフォルトの担当',
|
||||
defaultParen: '(既定)',
|
||||
autoDecompose: 'トリアージタスクを自動分解',
|
||||
profileDescriptions: 'プロフィールの説明',
|
||||
profileDescriptionsHint:
|
||||
'説明はデコンポーザーのルーティングを導きます。補助モデルで自動生成するか、自分で書いてください。',
|
||||
profileGoodAt: 'このプロフィールの得意分野は?',
|
||||
auto: '自動'
|
||||
}
|
||||
|
||||
const zh: KanbanMessages = {
|
||||
nav: '看板',
|
||||
openBoard: '看板:打开面板',
|
||||
newTaskCommand: '看板:新建任务',
|
||||
countTip: (running, ready) => `看板 — 运行中 ${running}、就绪 ${ready}`,
|
||||
col: {
|
||||
triage: { label: '分诊', help: '原始想法 — 由细化代理整理出规格。' },
|
||||
todo: { label: '待办', help: '等待依赖,或未分配。' },
|
||||
scheduled: { label: '已排期', help: '等待预定时间到来。' },
|
||||
ready: { label: '就绪', help: '依赖已满足 — 分配一个配置档,调度器即会运行它。' },
|
||||
running: { label: '运行中', help: '已被工作单元领取 — 有代理在处理。由调度器设置。' },
|
||||
blocked: { label: '受阻', help: '工作单元需要人工输入。' },
|
||||
review: { label: '审查', help: '审查代理正在检查工作。由调度器设置。' },
|
||||
done: { label: '完成', help: '已完成;依赖它的子任务变为就绪。' },
|
||||
archived: { label: '已归档', help: '从默认面板视图中隐藏。' }
|
||||
},
|
||||
locked: {
|
||||
review: '审查状态由调度器在审查代理领取卡片时设置。',
|
||||
running: '运行中由调度器在工作单元领取卡片时设置。',
|
||||
scheduled: '排期需要唤醒时间 — 由代理设置;无法拖入。'
|
||||
},
|
||||
arcRunning: '有代理正在处理它。',
|
||||
arcStale: '已领取,但超过 2 分钟没有工作单元心跳 — 调度器将重新领取。',
|
||||
title: '看板',
|
||||
orchestrationSettings: '编排设置',
|
||||
newTask: '新建任务',
|
||||
filterCards: '筛选卡片…',
|
||||
noMatch: '没有符合筛选条件的任务',
|
||||
noTasks: '此面板暂无任务',
|
||||
open: '打开',
|
||||
select: '选择(⌘点击)',
|
||||
deselect: '取消选择',
|
||||
moveTo: label => `移动到 ${label}`,
|
||||
delete: '删除',
|
||||
reviewChecking: '审查代理正在检查已完成的工作。',
|
||||
attachedTip: name => `${name} 已接手 — 调度器将在下一个周期(≤1 分钟)移交。`,
|
||||
orchestratorTip: name => `${name}(编排者)将在下一个周期领取并撰写规格。`,
|
||||
autoAssignTip: name => `将在下一个调度周期自动分配给“${name}”(kanban.default_assignee)。`,
|
||||
wontRun: '不会运行',
|
||||
wontRunTip: '就绪卡片只有在分配了配置档后才会运行。打开卡片设置负责人,或在编排设置中配置默认负责人。',
|
||||
noHeartbeat: '无心跳',
|
||||
expand: label => `展开 ${label}`,
|
||||
collapse: label => `折叠 ${label}`,
|
||||
newTaskIn: label => `在 ${label} 新建任务`,
|
||||
empty: '空',
|
||||
unassigned: '未分配',
|
||||
filters: '筛选',
|
||||
allProfiles: '所有配置档',
|
||||
allTenants: '所有租户',
|
||||
showArchived: '显示已归档',
|
||||
groupRunning: '按配置档分组运行中',
|
||||
nSelected: n => `已选择 ${n} 个`,
|
||||
moveToShort: '移动到',
|
||||
assign: '分配',
|
||||
unassignAction: '取消分配',
|
||||
archive: '归档',
|
||||
clearSelection: '清除选择(Esc)',
|
||||
refused: '被拒绝',
|
||||
bulkFailed: (failed, total, err) => `${total} 个中有 ${failed} 个失败 — ${err}。失败的卡片仍保持选中。`,
|
||||
titlePlaceholderTriage: '大致想法 — 细化代理会补全',
|
||||
titlePlaceholder: '标题',
|
||||
descPlaceholder: '描述(可选)',
|
||||
priority: '优先级',
|
||||
workspace: '工作区',
|
||||
boardDefaultSuffix: '・面板默认',
|
||||
workspaceOverride: '工作区路径(可选覆盖)',
|
||||
workspaceInherit: '继承面板的项目目录',
|
||||
workspaceInheritDir: dir => `留空则继承 ${dir}`,
|
||||
workspaceInheritGeneric: '留空则继承面板的项目目录。',
|
||||
assignee: '负责人',
|
||||
defaultOption: name => `${name}(默认)`,
|
||||
parkedOption: '未分配(搁置 — 不会运行)',
|
||||
skills: '技能(逗号分隔)',
|
||||
skillsPlaceholder: 'translation, github',
|
||||
parent: '父任务(完成前会阻塞)',
|
||||
noParent: '— 无父任务 —',
|
||||
goalMode: '目标模式(工作单元循环直到评判代理认可完成)',
|
||||
creating: '创建中…',
|
||||
createTask: '创建任务',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
estimate: '估算',
|
||||
estimateEffort: '估算工作量',
|
||||
estimating: '估算中…',
|
||||
reEstimate: '重新估算',
|
||||
makesModelCall: '会调用模型',
|
||||
estimateTip: '由辅助模型对令牌数和复杂度的粗略估算 — 会调用模型。',
|
||||
estimateTipLong: '快速调用辅助模型来估算令牌数和复杂度。仅供参考,并非账单。',
|
||||
roughEstimate: '粗略估算',
|
||||
tokUnit: 'tok',
|
||||
couldNotEstimate: '无法估算',
|
||||
complexity: { S: '小', M: '中', L: '大' },
|
||||
introBody:
|
||||
'卡片不由你运行,而是由代理运行。把带有负责人的卡片放入“就绪”,代理会在一分钟内领取。没有负责人就不会运行。分诊:代理先把想法改写成合适的任务。待办:等待其他卡片。已排期:等待计时器。运行中与审查:这是代理的通道,请勿插手。受阻:正在等你。结果会回到卡片上。',
|
||||
introGotIt: '知道了',
|
||||
evtCreated: (where, assignee) =>
|
||||
`已创建${where ? `(${where})` : ''}${assignee ? `・分配给 ${assignee}` : ''}`,
|
||||
evtMovedTo: col => `移动到 ${col}`,
|
||||
evtParentReopened: parent => `父任务 ${parent} 已重新打开`,
|
||||
evtAssignedTo: assignee => `分配给 ${assignee}`,
|
||||
evtUnassigned: '取消分配',
|
||||
evtCommentBy: author => `${author} 的评论`,
|
||||
evtClaimedReview: '被审查代理领取',
|
||||
evtClaimedWorker: '被工作单元领取',
|
||||
evtWorkerStarted: '工作单元已启动',
|
||||
evtCompleted: '已完成',
|
||||
evtBlocked: '受阻 — 需要人工输入',
|
||||
evtUnblocked: col => `已解除阻塞${col ? ` → ${col}` : ' → 就绪'}`,
|
||||
evtReclaimed: '已重新领取 — 已放回队列',
|
||||
evtSpecified: '分诊代理已撰写规格',
|
||||
evtPromoted: '依赖已完成 — 提升为就绪',
|
||||
evtScheduled: '已排期稍后运行',
|
||||
evtArchived: '已归档',
|
||||
evtReprioritized: priority => `优先级设为 ${priority}`,
|
||||
someone: '某人',
|
||||
metaPriority: '优先级',
|
||||
metaTenant: '租户',
|
||||
metaCreatedBy: '创建者',
|
||||
metaCreated: '创建于',
|
||||
metaWorkerPid: '工作单元 PID',
|
||||
readyUnassignedTitle: '就绪但未分配 — 这张卡片永远不会运行。',
|
||||
readyUnassignedBody:
|
||||
'调度器只领取有负责人的就绪卡片。在上面的负责人字段选择一个配置档(或在编排设置中设置默认负责人),它会在一分钟内运行。',
|
||||
diagnosticsN: n => `诊断・${n}`,
|
||||
commandCopied: '命令已复制',
|
||||
description: '描述',
|
||||
editDescription: '编辑描述',
|
||||
cancelEdit: '取消编辑',
|
||||
noDescription: '暂无描述。',
|
||||
result: '结果',
|
||||
latestSummary: '最新摘要',
|
||||
dependencies: '依赖关系',
|
||||
blockedBy: '受阻于',
|
||||
blocks: '阻塞',
|
||||
comments: n => `评论・${n}`,
|
||||
commentsHelpRunning:
|
||||
'此任务正在运行。你的备注会在几秒内融入工作单元当前的回合 — 无需阻塞/解除操作。选择“附带备注重新入队”则会带着你的备注从头重跑任务。',
|
||||
commentsHelp: '评论会添加到任务讨论串。工作单元领取任务时会将其作为上下文的一部分读取。',
|
||||
send: '发送',
|
||||
comment: '评论',
|
||||
messageWorker: '给运行中的工作单元发消息…',
|
||||
addComment: '添加评论…',
|
||||
deliveredLive: '几秒内送达运行中的工作单元。',
|
||||
requeueWithNote: '附带备注重新入队',
|
||||
notePosted: '备注已发布 — 工作单元已重新入队',
|
||||
activity: n => `活动・${n}`,
|
||||
runs: n => `运行・${n}`,
|
||||
workerLog: '工作单元日志',
|
||||
workerLogTail: '工作单元日志・末尾',
|
||||
attachments: n => `附件・${n}`,
|
||||
noAttachments: '暂无附件。',
|
||||
uploadAttachment: '上传附件',
|
||||
taskActions: '任务操作',
|
||||
copyTaskId: '复制任务 ID',
|
||||
copyTitle: '复制标题',
|
||||
copiedId: id => `已复制 ${id}`,
|
||||
copiedTitle: '已复制标题',
|
||||
archiveTask: '归档任务',
|
||||
deleteTask: '删除任务',
|
||||
close: '关闭',
|
||||
working: '进行中',
|
||||
board: '面板',
|
||||
newBoard: '新建面板',
|
||||
newBoardDots: '新建面板…',
|
||||
boardSettings: '面板设置…',
|
||||
boardSettingsFor: name => `面板设置 — ${name}`,
|
||||
name: '名称',
|
||||
boardNamePlaceholder: '面板名称',
|
||||
slug: slug => `slug: ${slug}`,
|
||||
project: '项目',
|
||||
noProject: '无项目(临时沙箱)',
|
||||
projectHintPre: '新任务将在项目的仓库中运行(每个任务一个 worktree);每个任务在创建时仍可覆盖其工作区。管理项目请使用 ',
|
||||
projectHintCmd: 'hermes project',
|
||||
createBoard: '创建面板',
|
||||
orchestratorProfile: '编排者配置档',
|
||||
defaultAssignee: '默认负责人',
|
||||
defaultParen: '(默认)',
|
||||
autoDecompose: '自动分解分诊任务',
|
||||
profileDescriptions: '配置档说明',
|
||||
profileDescriptionsHint: '说明用于引导分解器的路由。可用辅助模型自动生成,或自行填写。',
|
||||
profileGoodAt: '这个配置档擅长什么?',
|
||||
auto: '自动'
|
||||
}
|
||||
|
||||
const zhHant: KanbanMessages = {
|
||||
nav: '看板',
|
||||
openBoard: '看板:開啟面板',
|
||||
newTaskCommand: '看板:新增任務',
|
||||
countTip: (running, ready) => `看板 — 執行中 ${running}、就緒 ${ready}`,
|
||||
col: {
|
||||
triage: { label: '分類', help: '原始想法 — 由細化代理整理出規格。' },
|
||||
todo: { label: '待辦', help: '等待相依項目,或未指派。' },
|
||||
scheduled: { label: '已排程', help: '等待預定時間到來。' },
|
||||
ready: { label: '就緒', help: '相依項目已滿足 — 指派一個設定檔,排程器便會執行它。' },
|
||||
running: { label: '執行中', help: '已被工作單元領取 — 有代理在處理。由排程器設定。' },
|
||||
blocked: { label: '受阻', help: '工作單元需要人工輸入。' },
|
||||
review: { label: '審查', help: '審查代理正在檢查工作。由排程器設定。' },
|
||||
done: { label: '完成', help: '已完成;相依它的子任務變為就緒。' },
|
||||
archived: { label: '已封存', help: '從預設面板檢視中隱藏。' }
|
||||
},
|
||||
locked: {
|
||||
review: '審查狀態由排程器在審查代理領取卡片時設定。',
|
||||
running: '執行中由排程器在工作單元領取卡片時設定。',
|
||||
scheduled: '排程需要喚醒時間 — 由代理設定;無法拖入。'
|
||||
},
|
||||
arcRunning: '有代理正在處理它。',
|
||||
arcStale: '已領取,但超過 2 分鐘沒有工作單元心跳 — 排程器將重新領取。',
|
||||
title: '看板',
|
||||
orchestrationSettings: '編排設定',
|
||||
newTask: '新增任務',
|
||||
filterCards: '篩選卡片…',
|
||||
noMatch: '沒有符合篩選條件的任務',
|
||||
noTasks: '此面板尚無任務',
|
||||
open: '開啟',
|
||||
select: '選取(⌘點擊)',
|
||||
deselect: '取消選取',
|
||||
moveTo: label => `移至 ${label}`,
|
||||
delete: '刪除',
|
||||
reviewChecking: '審查代理正在檢查已完成的工作。',
|
||||
attachedTip: name => `${name} 已接手 — 排程器將在下一個週期(≤1 分鐘)移交。`,
|
||||
orchestratorTip: name => `${name}(編排者)將在下一個週期領取並撰寫規格。`,
|
||||
autoAssignTip: name => `將在下一個排程週期自動指派給「${name}」(kanban.default_assignee)。`,
|
||||
wontRun: '不會執行',
|
||||
wontRunTip: '就緒卡片只有在指派了設定檔後才會執行。開啟卡片設定負責人,或在編排設定中設定預設負責人。',
|
||||
noHeartbeat: '無心跳',
|
||||
expand: label => `展開 ${label}`,
|
||||
collapse: label => `摺疊 ${label}`,
|
||||
newTaskIn: label => `在 ${label} 新增任務`,
|
||||
empty: '空',
|
||||
unassigned: '未指派',
|
||||
filters: '篩選',
|
||||
allProfiles: '所有設定檔',
|
||||
allTenants: '所有租戶',
|
||||
showArchived: '顯示已封存',
|
||||
groupRunning: '依設定檔分組執行中',
|
||||
nSelected: n => `已選取 ${n} 個`,
|
||||
moveToShort: '移至',
|
||||
assign: '指派',
|
||||
unassignAction: '取消指派',
|
||||
archive: '封存',
|
||||
clearSelection: '清除選取(Esc)',
|
||||
refused: '被拒絕',
|
||||
bulkFailed: (failed, total, err) => `${total} 個中有 ${failed} 個失敗 — ${err}。失敗的卡片仍保持選取。`,
|
||||
titlePlaceholderTriage: '大致想法 — 細化代理會補全',
|
||||
titlePlaceholder: '標題',
|
||||
descPlaceholder: '描述(選填)',
|
||||
priority: '優先順序',
|
||||
workspace: '工作區',
|
||||
boardDefaultSuffix: '・面板預設',
|
||||
workspaceOverride: '工作區路徑(選填覆寫)',
|
||||
workspaceInherit: '繼承面板的專案目錄',
|
||||
workspaceInheritDir: dir => `留空則繼承 ${dir}`,
|
||||
workspaceInheritGeneric: '留空則繼承面板的專案目錄。',
|
||||
assignee: '負責人',
|
||||
defaultOption: name => `${name}(預設)`,
|
||||
parkedOption: '未指派(擱置 — 不會執行)',
|
||||
skills: '技能(以逗號分隔)',
|
||||
skillsPlaceholder: 'translation, github',
|
||||
parent: '父任務(完成前會阻擋)',
|
||||
noParent: '— 無父任務 —',
|
||||
goalMode: '目標模式(工作單元循環直到評判代理認可完成)',
|
||||
creating: '建立中…',
|
||||
createTask: '建立任務',
|
||||
cancel: '取消',
|
||||
save: '儲存',
|
||||
estimate: '估算',
|
||||
estimateEffort: '估算工作量',
|
||||
estimating: '估算中…',
|
||||
reEstimate: '重新估算',
|
||||
makesModelCall: '會呼叫模型',
|
||||
estimateTip: '由輔助模型對 token 數與複雜度的粗略估算 — 會呼叫模型。',
|
||||
estimateTipLong: '快速呼叫輔助模型來估算 token 數與複雜度。僅供參考,並非帳單。',
|
||||
roughEstimate: '粗略估算',
|
||||
tokUnit: 'tok',
|
||||
couldNotEstimate: '無法估算',
|
||||
complexity: { S: '小', M: '中', L: '大' },
|
||||
introBody:
|
||||
'卡片不由你執行,而是由代理執行。把有負責人的卡片放入「就緒」,代理會在一分鐘內領取。沒有負責人就不會執行。分類:代理先把想法改寫成合適的任務。待辦:等待其他卡片。已排程:等待計時器。執行中與審查:這是代理的通道,請勿插手。受阻:正在等你。結果會回到卡片上。',
|
||||
introGotIt: '知道了',
|
||||
evtCreated: (where, assignee) =>
|
||||
`已建立${where ? `(${where})` : ''}${assignee ? `・指派給 ${assignee}` : ''}`,
|
||||
evtMovedTo: col => `移至 ${col}`,
|
||||
evtParentReopened: parent => `父任務 ${parent} 已重新開啟`,
|
||||
evtAssignedTo: assignee => `指派給 ${assignee}`,
|
||||
evtUnassigned: '取消指派',
|
||||
evtCommentBy: author => `${author} 的留言`,
|
||||
evtClaimedReview: '被審查代理領取',
|
||||
evtClaimedWorker: '被工作單元領取',
|
||||
evtWorkerStarted: '工作單元已啟動',
|
||||
evtCompleted: '已完成',
|
||||
evtBlocked: '受阻 — 需要人工輸入',
|
||||
evtUnblocked: col => `已解除阻擋${col ? ` → ${col}` : ' → 就緒'}`,
|
||||
evtReclaimed: '已重新領取 — 已放回佇列',
|
||||
evtSpecified: '分類代理已撰寫規格',
|
||||
evtPromoted: '相依項目已完成 — 提升為就緒',
|
||||
evtScheduled: '已排程稍後執行',
|
||||
evtArchived: '已封存',
|
||||
evtReprioritized: priority => `優先順序設為 ${priority}`,
|
||||
someone: '某人',
|
||||
metaPriority: '優先順序',
|
||||
metaTenant: '租戶',
|
||||
metaCreatedBy: '建立者',
|
||||
metaCreated: '建立於',
|
||||
metaWorkerPid: '工作單元 PID',
|
||||
readyUnassignedTitle: '就緒但未指派 — 這張卡片永遠不會執行。',
|
||||
readyUnassignedBody:
|
||||
'排程器只領取有負責人的就緒卡片。在上方的負責人欄位選擇一個設定檔(或在編排設定中設定預設負責人),它會在一分鐘內執行。',
|
||||
diagnosticsN: n => `診斷・${n}`,
|
||||
commandCopied: '指令已複製',
|
||||
description: '描述',
|
||||
editDescription: '編輯描述',
|
||||
cancelEdit: '取消編輯',
|
||||
noDescription: '尚無描述。',
|
||||
result: '結果',
|
||||
latestSummary: '最新摘要',
|
||||
dependencies: '相依關係',
|
||||
blockedBy: '受阻於',
|
||||
blocks: '阻擋',
|
||||
comments: n => `留言・${n}`,
|
||||
commentsHelpRunning:
|
||||
'此任務正在執行。你的備註會在幾秒內融入工作單元目前的回合 — 無需阻擋/解除操作。選擇「附上備註重新排入佇列」則會帶著你的備註從頭重跑任務。',
|
||||
commentsHelp: '留言會加入任務討論串。工作單元領取任務時會將其作為脈絡的一部分讀取。',
|
||||
send: '傳送',
|
||||
comment: '留言',
|
||||
messageWorker: '傳訊給執行中的工作單元…',
|
||||
addComment: '新增留言…',
|
||||
deliveredLive: '幾秒內送達執行中的工作單元。',
|
||||
requeueWithNote: '附上備註重新排入佇列',
|
||||
notePosted: '備註已發布 — 工作單元已重新排入佇列',
|
||||
activity: n => `活動・${n}`,
|
||||
runs: n => `執行・${n}`,
|
||||
workerLog: '工作單元日誌',
|
||||
workerLogTail: '工作單元日誌・末尾',
|
||||
attachments: n => `附件・${n}`,
|
||||
noAttachments: '尚無附件。',
|
||||
uploadAttachment: '上傳附件',
|
||||
taskActions: '任務操作',
|
||||
copyTaskId: '複製任務 ID',
|
||||
copyTitle: '複製標題',
|
||||
copiedId: id => `已複製 ${id}`,
|
||||
copiedTitle: '已複製標題',
|
||||
archiveTask: '封存任務',
|
||||
deleteTask: '刪除任務',
|
||||
close: '關閉',
|
||||
working: '進行中',
|
||||
board: '面板',
|
||||
newBoard: '新增面板',
|
||||
newBoardDots: '新增面板…',
|
||||
boardSettings: '面板設定…',
|
||||
boardSettingsFor: name => `面板設定 — ${name}`,
|
||||
name: '名稱',
|
||||
boardNamePlaceholder: '面板名稱',
|
||||
slug: slug => `slug: ${slug}`,
|
||||
project: '專案',
|
||||
noProject: '無專案(暫存沙箱)',
|
||||
projectHintPre: '新任務將在專案的儲存庫中執行(每個任務一個 worktree);每個任務在建立時仍可覆寫其工作區。管理專案請使用 ',
|
||||
projectHintCmd: 'hermes project',
|
||||
createBoard: '建立面板',
|
||||
orchestratorProfile: '編排者設定檔',
|
||||
defaultAssignee: '預設負責人',
|
||||
defaultParen: '(預設)',
|
||||
autoDecompose: '自動分解分類任務',
|
||||
profileDescriptions: '設定檔說明',
|
||||
profileDescriptionsHint: '說明用於引導分解器的路由。可用輔助模型自動產生,或自行填寫。',
|
||||
profileGoodAt: '這個設定檔擅長什麼?',
|
||||
auto: '自動'
|
||||
}
|
||||
|
||||
/** Registered via `ctx.i18n.register` at plugin load (disposer tracked). */
|
||||
export const KANBAN_LOCALES: PluginLocaleBundles = { en, ja, zh, 'zh-hant': zhHant }
|
||||
|
||||
// Bind the message SHAPE to a plugin translator: string leaves resolve now,
|
||||
// function leaves forward their args through t(path, …). One tiny generic
|
||||
// instead of a hand-written accessor per key.
|
||||
type Bound<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer A) => string
|
||||
? (...args: A) => string
|
||||
: T[K] extends object
|
||||
? Bound<T[K]>
|
||||
: string
|
||||
}
|
||||
|
||||
function bind<T extends object>(t: PluginTranslate, template: T, prefix = ''): Bound<T> {
|
||||
const out = {} as Record<string, unknown>
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
out[key] =
|
||||
typeof value === 'function'
|
||||
? (...args: unknown[]) => t(path, ...args)
|
||||
: value && typeof value === 'object'
|
||||
? bind(t, value as object, path)
|
||||
: t(path)
|
||||
}
|
||||
|
||||
return out as Bound<T>
|
||||
}
|
||||
|
||||
export type KanbanText = Bound<KanbanMessages>
|
||||
|
||||
/** The kanban strings for the active locale — one hook every component reads. */
|
||||
export function useKanban(): KanbanText {
|
||||
const t = usePluginI18n('kanban')
|
||||
|
||||
return useMemo(() => bind(t, en), [t])
|
||||
}
|
||||
|
||||
// Column labels/help live in i18n; unknown backend statuses fall back to the id.
|
||||
export const columnLabel = (k: KanbanText, name: string) => k.col[name as keyof KanbanText['col']]?.label ?? name
|
||||
export const columnHelp = (k: KanbanText, name: string) => k.col[name as keyof KanbanText['col']]?.help ?? ''
|
||||
export const lockedReason = (k: KanbanText, name: string) => k.locked[name as keyof KanbanText['locked']] ?? ''
|
||||
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;
|
||||
}
|
||||
}
|
||||
181
apps/desktop/src/plugins/kanban/orchestration.tsx
Normal file
181
apps/desktop/src/plugins/kanban/orchestration.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* 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, useKanban } from './ui'
|
||||
|
||||
const DEFAULT_SENTINEL = '__default__'
|
||||
|
||||
function ProfilePicker({
|
||||
label,
|
||||
onSave,
|
||||
profiles,
|
||||
value
|
||||
}: {
|
||||
label: string
|
||||
onSave: (name: string) => void
|
||||
profiles: KanbanProfile[]
|
||||
value: string
|
||||
}) {
|
||||
const k = useKanban()
|
||||
|
||||
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}>{k.defaultParen}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
|
||||
const k = useKanban()
|
||||
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)">{k.defaultParen}</span>}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 flex-1 text-[0.71rem]"
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
placeholder={k.profileGoodAt}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
disabled={save.isPending || draft.trim() === profile.description}
|
||||
onClick={() => save.mutate()}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{k.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' : ''}>{k.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 k = useKanban()
|
||||
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={k.orchestratorProfile}
|
||||
onSave={name => save.mutate({ orchestrator_profile: name })}
|
||||
profiles={roster.profiles}
|
||||
value={settings.orchestrator_profile}
|
||||
/>
|
||||
<ProfilePicker
|
||||
label={k.defaultAssignee}
|
||||
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={k.autoDecompose}
|
||||
checked={settings.auto_decompose}
|
||||
onCheckedChange={checked => save.mutate({ auto_decompose: checked })}
|
||||
size="xs"
|
||||
/>
|
||||
{k.autoDecompose}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={FIELD_LABEL}>{k.profileDescriptions}</span>
|
||||
<p className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.profileDescriptionsHint}</p>
|
||||
{roster.profiles.map(profile => (
|
||||
<ProfileDescriptionRow key={`${profile.name}:${profile.description}`} profile={profile} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
apps/desktop/src/plugins/kanban/plugin.tsx
Normal file
160
apps/desktop/src/plugins/kanban/plugin.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* 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,
|
||||
type KeybindContribution,
|
||||
KEYBINDS_AREA,
|
||||
PALETTE_AREA,
|
||||
type PaletteContribution,
|
||||
type RouteContribution,
|
||||
ROUTES_AREA,
|
||||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
STATUSBAR_AREAS,
|
||||
Tip,
|
||||
useQuery,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
|
||||
import { $boardSlug, bindApi, boardKey, fetchBoard } from './api'
|
||||
import { KanbanBoardPage } from './board'
|
||||
import { KANBAN_LOCALES } from './i18n'
|
||||
import { $newTaskLane, useKanban } from './ui'
|
||||
|
||||
// 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 k = useKanban()
|
||||
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 (
|
||||
<Tip label={k.countTip(count('running'), count('ready'))}>
|
||||
<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')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="project" size="0.7rem" />
|
||||
<span>{active}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
const plugin: HermesPlugin = {
|
||||
id: 'kanban',
|
||||
name: 'Kanban',
|
||||
defaultEnabled: false,
|
||||
register(ctx) {
|
||||
ctx.i18n.register(KANBAN_LOCALES)
|
||||
ctx.onDispose(bindApi(ctx.rest, ctx.storage, ctx.socket))
|
||||
|
||||
// The plugin command pattern: ONE action id (`kanban.newTask`) wired into
|
||||
// two areas — a keybind (dispatch + rebindable panel row) and a palette row
|
||||
// whose `action` field points back at it, so ⌘K shows the live combo. The
|
||||
// handler is route-independent: it navigates to the page and parks the
|
||||
// request in `$newTaskLane`, so the hotkey works from anywhere, not just
|
||||
// while the board happens to be mounted.
|
||||
//
|
||||
// ⌘⌥N / Ctrl+Alt+N: `mod+n` is `session.new` and `mod+shift+n` is
|
||||
// `session.newWindow`, both core built-ins a plugin can't shadow. Adding
|
||||
// Alt keeps the "N for new" mnemonic on a chord core leaves free — it uses
|
||||
// `alt` only for the `mod+alt+1…9` profile slots, never with a letter. That
|
||||
// makes ⌘⌥<letter> the natural namespace for plugin commands.
|
||||
const newTask = () => {
|
||||
$newTaskLane.set('triage')
|
||||
host.navigate('/kanban')
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
id: 'new-task',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'kanban.newTask',
|
||||
action: 'kanban.newTask',
|
||||
label: ctx.i18n.t('newTaskCommand'),
|
||||
keywords: ['kanban', 'task', 'new', 'create', 'triage'],
|
||||
run: newTask
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
{
|
||||
id: 'new-task',
|
||||
area: KEYBINDS_AREA,
|
||||
data: {
|
||||
id: 'kanban.newTask',
|
||||
category: 'view',
|
||||
defaults: ['mod+alt+n'],
|
||||
label: ctx.i18n.t('newTaskCommand'),
|
||||
run: newTask
|
||||
} satisfies KeybindContribution
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
||||
209
apps/desktop/src/plugins/kanban/types.ts
Normal file
209
apps/desktop/src/plugins/kanban/types.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/** 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
|
||||
description?: null | string
|
||||
is_current?: boolean
|
||||
total?: number
|
||||
/** Board-level project directory new tasks inherit (empty = none). */
|
||||
default_workdir?: null | string
|
||||
/** Recommended workspace kind derived from default_workdir by the backend
|
||||
* (`scratch` when unset, `worktree` in a git repo, else `dir`). */
|
||||
default_workspace_kind?: null | string
|
||||
/** First-class Project the board is scoped to (id) + resolved name. */
|
||||
project_id?: null | string
|
||||
project_name?: null | string
|
||||
}
|
||||
|
||||
/** GET /projects — first-class Hermes projects available to scope a board. */
|
||||
export interface KanbanProject {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
primary_path?: null | string
|
||||
icon?: null | string
|
||||
color?: null | string
|
||||
}
|
||||
|
||||
/** POST /tasks/:id/estimate — rough auxiliary-model estimate (never dollars). */
|
||||
export interface TaskEstimate {
|
||||
ok: boolean
|
||||
reason?: null | string
|
||||
est_tokens?: number
|
||||
complexity?: 'L' | 'M' | 'S' | null
|
||||
rationale?: null | string
|
||||
model?: null | string
|
||||
}
|
||||
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 — codicon + tone only. Labels + help live in i18n
|
||||
* (plugin bundles); see `columnLabel`/`columnHelp` in i18n.ts. Order follows
|
||||
* the backend's BOARD_COLUMNS; anything the backend adds renders via the
|
||||
* fallback. */
|
||||
export const COLUMN_META: Record<string, { codicon: string; tone: string }> = {
|
||||
triage: { codicon: 'inbox', tone: 'var(--ui-text-tertiary)' },
|
||||
todo: { codicon: 'circle-outline', tone: 'var(--ui-text-secondary)' },
|
||||
scheduled: { codicon: 'watch', tone: '#a78bfa' },
|
||||
ready: { codicon: 'play-circle', tone: '#60a5fa' },
|
||||
running: { codicon: 'sync', tone: '#34d399' },
|
||||
blocked: { codicon: 'error', tone: '#f87171' },
|
||||
review: { codicon: 'eye', tone: '#fbbf24' },
|
||||
done: { codicon: 'pass', tone: 'var(--ui-text-tertiary)' },
|
||||
archived: { codicon: 'archive', tone: 'var(--ui-text-quaternary)' }
|
||||
}
|
||||
|
||||
export const columnMeta = (name: string) =>
|
||||
COLUMN_META[name] ?? { codicon: 'circle-outline', tone: 'var(--ui-text-secondary)' }
|
||||
|
||||
export const SEVERITY_TONE: Record<Diagnostic['severity'], string> = {
|
||||
critical: 'var(--destructive, #f87171)',
|
||||
error: 'var(--destructive, #f87171)',
|
||||
warning: '#fbbf24'
|
||||
}
|
||||
336
apps/desktop/src/plugins/kanban/ui.tsx
Normal file
336
apps/desktop/src/plugins/kanban/ui.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/** Shared kanban UI atoms: formatters, the identity avatar, the status menu,
|
||||
* section chrome, and the masked scroller. Pure SDK + tokens. */
|
||||
|
||||
import {
|
||||
atom,
|
||||
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 { columnLabel, useKanban } from './i18n'
|
||||
import { columnMeta, type KanbanTask } from './types'
|
||||
|
||||
// Plugin-scoped i18n lives in ./i18n; re-exported so components import strings
|
||||
// and chrome from one place (./ui).
|
||||
export { columnHelp, columnLabel, type KanbanText, lockedReason, useKanban } from './i18n'
|
||||
|
||||
/** One-shot "open the new-task dialog in this lane" request, so a command that
|
||||
* fires from ANYWHERE (keybind, palette) can reach the board page without the
|
||||
* page having to exist yet: the handler navigates and drops the lane here, the
|
||||
* page consumes it on arrival and clears it. Ephemeral by design — never
|
||||
* persisted, so a remount can't reopen a dialog the user already dismissed. */
|
||||
export const $newTaskLane = atom<null | string>(null)
|
||||
|
||||
/** 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). The reason
|
||||
// copy lives in the plugin i18n bundle (`locked.*`); see `lockedReason`.
|
||||
export const LOCKED_COLUMNS = ['review', 'running', 'scheduled'] as const
|
||||
|
||||
export const isLockedTarget = (name: string): boolean => (LOCKED_COLUMNS as readonly string[]).includes(name)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Ticking "working · 34s" line for running cards (elapsed since claim). */
|
||||
export function RunClock({ task }: { task: KanbanTask }) {
|
||||
const k = useKanban()
|
||||
const elapsed = useTicking(task.started_at)
|
||||
|
||||
if (!elapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="shrink-0 font-medium" style={{ color: columnMeta('running').tone }}>
|
||||
{k.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 k = useKanban()
|
||||
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 }} />
|
||||
{columnLabel(k, status)}
|
||||
<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 }} />
|
||||
{columnLabel(k, name)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -200,6 +200,9 @@ export type {
|
|||
* id with your plugin slug (`kanban:board-switcher`). */
|
||||
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
|
||||
export type { Contribution } from '@/contrib/types'
|
||||
/** Grab-to-pan for overflow containers (boards, timelines, wide tables) —
|
||||
* the shared scrub primitive; don't hand-roll drag-to-scroll. */
|
||||
export { type GrabScroll, useGrabScroll } from '@/hooks/use-grab-scroll'
|
||||
/** Localized copy. `useI18n` reuses the app's strings; `usePluginI18n(id)` +
|
||||
* `ctx.i18n.register` let a plugin ship its OWN locale bundles, scoped like
|
||||
* `ctx.storage` and resolved against the app's active locale — no core edit. */
|
||||
|
|
@ -213,6 +216,9 @@ export {
|
|||
useI18n,
|
||||
usePluginI18n
|
||||
} from '@/i18n'
|
||||
/** THE compact-number formatter — every user-facing count/token figure goes
|
||||
* through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */
|
||||
export { compactNumber } from '@/lib/format'
|
||||
export { triggerHaptic as haptic } from '@/lib/haptics'
|
||||
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
|
||||
export * as icons from '@/lib/icons'
|
||||
|
|
@ -234,8 +240,6 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left'
|
|||
* setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll
|
||||
* readiness from raw RPC shapes. */
|
||||
export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
/** Canonical time formatting — every timestamp/age string in the app comes
|
||||
* from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */
|
||||
export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time'
|
||||
export { cn } from '@/lib/utils'
|
||||
export { THEMES_AREA } from '@/themes/user-themes'
|
||||
|
|
|
|||
|
|
@ -673,6 +673,11 @@ def read_board_metadata(board: Optional[str] = None) -> dict:
|
|||
"icon": "",
|
||||
"color": "",
|
||||
"default_workdir": None,
|
||||
# Optional first-class Project this board is scoped to. When set, new
|
||||
# tasks inherit it (deterministic worktree + branch under the project's
|
||||
# primary repo) and ``default_workdir`` mirrors the project's primary
|
||||
# path so the persistent-workspace inheritance path keeps working.
|
||||
"project_id": None,
|
||||
"created_at": None,
|
||||
"archived": False,
|
||||
}
|
||||
|
|
@ -700,11 +705,16 @@ def write_board_metadata(
|
|||
color: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
default_workdir: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create / update ``board.json`` for ``board``.
|
||||
|
||||
Preserves any existing fields not mentioned in the call. Sets
|
||||
``created_at`` on first write. Returns the resulting metadata dict.
|
||||
|
||||
``project_id``: ``None`` leaves it unchanged; empty string clears the
|
||||
project scope; a value sets it (not validated here — the caller resolves
|
||||
it against ``projects_db``).
|
||||
"""
|
||||
_assert_not_delegated_child_mutation()
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
|
|
@ -724,6 +734,8 @@ def write_board_metadata(
|
|||
meta["archived"] = bool(archived)
|
||||
if default_workdir is not None:
|
||||
meta["default_workdir"] = str(default_workdir) if default_workdir else None
|
||||
if project_id is not None:
|
||||
meta["project_id"] = str(project_id) if project_id else None
|
||||
if not meta.get("created_at"):
|
||||
meta["created_at"] = int(time.time())
|
||||
path = board_metadata_path(slug)
|
||||
|
|
@ -744,6 +756,7 @@ def create_board(
|
|||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
default_workdir: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a new board directory + DB + metadata. Idempotent.
|
||||
|
||||
|
|
@ -761,6 +774,7 @@ def create_board(
|
|||
icon=icon,
|
||||
color=color,
|
||||
default_workdir=default_workdir,
|
||||
project_id=project_id,
|
||||
)
|
||||
# Touch the DB so list_boards() sees it immediately.
|
||||
init_db(board=normed)
|
||||
|
|
@ -2900,6 +2914,18 @@ def create_task(
|
|||
if branch_name and workspace_kind != "worktree":
|
||||
raise ValueError("branch_name is only valid for worktree workspaces")
|
||||
|
||||
# Inherit the board's scoped project when the caller didn't name one, so a
|
||||
# project-scoped board anchors every new task to that project's repo
|
||||
# (deterministic worktree + branch) without each surface repeating it.
|
||||
if project_id is None:
|
||||
try:
|
||||
_bmeta = read_board_metadata(board if board else get_current_board())
|
||||
_board_project = (_bmeta.get("project_id") or "").strip()
|
||||
if _board_project:
|
||||
project_id = _board_project
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve an optional first-class Project link. A project-linked task is
|
||||
# anchored to the project's primary repo as a git worktree, so its branch
|
||||
# can be named deterministically (project slug + task id) instead of the
|
||||
|
|
@ -3554,6 +3580,33 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
|||
]
|
||||
|
||||
|
||||
def list_comments_after(
|
||||
conn: sqlite3.Connection, task_id: str, *, after_id: int = 0
|
||||
) -> list[Comment]:
|
||||
"""Return comments on ``task_id`` with ``id > after_id`` (ascending).
|
||||
|
||||
Keyed on the monotonic rowid rather than ``created_at`` so a same-second
|
||||
burst can't be skipped. Used by the live worker bridge to fold new
|
||||
operator notes into a running task without a restart (see
|
||||
``tools.kanban_tools.inject_new_comments_from_env``).
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id, task_id, author, body, created_at FROM task_comments "
|
||||
"WHERE task_id = ? AND id > ? ORDER BY id ASC",
|
||||
(task_id, int(after_id)),
|
||||
).fetchall()
|
||||
return [
|
||||
Comment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
author=r["author"],
|
||||
body=r["body"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
|
|||
goal_max_turns: Optional[int] = None
|
||||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
# Explicit project link; when omitted, create_task inherits the board's
|
||||
# scoped project (if any) so a project-scoped board anchors every task.
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
|
|
@ -636,6 +639,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
|||
goal_max_turns=payload.goal_max_turns,
|
||||
model_override=payload.model_override,
|
||||
provider_override=payload.provider_override,
|
||||
project_id=payload.project_id,
|
||||
board=board,
|
||||
)
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
|
||||
|
|
@ -1735,6 +1740,134 @@ def reassign_task_endpoint(
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Estimate — a rough token/complexity estimate for a task via the auxiliary
|
||||
# (auto-routed) model. NOT a dollar cost: providers don't report cost
|
||||
# reliably, so we estimate tokens + a complexity band with a one-line why.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ESTIMATE_SYSTEM_PROMPT = (
|
||||
"You estimate how much work an autonomous coding agent will spend on a "
|
||||
"kanban task. Given the task title and description, respond with STRICT "
|
||||
"JSON only (no prose, no code fence):\n"
|
||||
'{"est_tokens": <integer total tokens across the whole run>, '
|
||||
'"complexity": "S"|"M"|"L", '
|
||||
'"rationale": "<one short sentence>"}\n'
|
||||
"Base the token figure on a realistic multi-turn agent run (reading files, "
|
||||
"tool calls, edits, retries) — not a single reply. S≈small/localized, "
|
||||
"M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess."
|
||||
)
|
||||
|
||||
|
||||
class EstimateBody(BaseModel):
|
||||
title: str = ""
|
||||
body: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/estimate")
|
||||
def estimate_text_endpoint(payload: EstimateBody):
|
||||
"""Estimate from raw title/body — used by the create dialog before a task
|
||||
exists yet. Same outcome shape as the per-task endpoint below."""
|
||||
return _run_estimate(payload.title, payload.body)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/estimate")
|
||||
def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
|
||||
"""Rough token + complexity estimate for an existing task via the auxiliary
|
||||
model. Returns ``{ok, est_tokens, complexity, rationale, model}``; a non-OK
|
||||
outcome is NOT an HTTP error. Runs in FastAPI's threadpool (sync ``def``)
|
||||
because the LLM call can take several seconds.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return _run_estimate(task.title, task.body)
|
||||
|
||||
|
||||
def _run_estimate(title: str, body: Optional[str]) -> dict:
|
||||
"""Shared estimate core: ask the auto-routed auxiliary model for a rough
|
||||
token + complexity read on a task described by ``title``/``body``.
|
||||
|
||||
Never raises — a bad config / parse / API error becomes
|
||||
``{"ok": False, "reason": ...}`` so the UI can render it inline.
|
||||
"""
|
||||
if not (title or "").strip():
|
||||
return {"ok": False, "reason": "a title is required to estimate"}
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
except Exception:
|
||||
return {"ok": False, "reason": "auxiliary client unavailable"}
|
||||
|
||||
def _cap(s: Optional[str], n: int) -> str:
|
||||
s = (s or "").strip()
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
user_msg = (
|
||||
f"Title: {_cap(title, 400)}\n\n"
|
||||
f"Description:\n{_cap(body, 4000) or '(none)'}"
|
||||
)
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="kanban_estimator",
|
||||
messages=[
|
||||
{"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.0,
|
||||
max_tokens=300,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
|
||||
|
||||
try:
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
model = getattr(resp, "model", None)
|
||||
except Exception:
|
||||
raw, model = "", None
|
||||
|
||||
# Reuse the same tolerant JSON-blob extraction the specifier uses.
|
||||
parsed: Optional[dict] = None
|
||||
try:
|
||||
import json as _json
|
||||
import re as _re
|
||||
blob = raw
|
||||
if not blob.lstrip().startswith("{"):
|
||||
m = _re.search(r"\{.*\}", blob, _re.DOTALL)
|
||||
blob = m.group(0) if m else blob
|
||||
obj = _json.loads(blob)
|
||||
if isinstance(obj, dict):
|
||||
parsed = obj
|
||||
except Exception:
|
||||
parsed = None
|
||||
|
||||
if not parsed:
|
||||
return {"ok": False, "reason": "could not parse an estimate from the model"}
|
||||
|
||||
try:
|
||||
est_tokens = int(parsed.get("est_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
est_tokens = 0
|
||||
complexity = str(parsed.get("complexity") or "").strip().upper()
|
||||
if complexity not in {"S", "M", "L"}:
|
||||
complexity = None
|
||||
rationale = str(parsed.get("rationale") or "").strip() or None
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"est_tokens": est_tokens,
|
||||
"complexity": complexity,
|
||||
"rationale": rationale,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -2078,6 +2211,10 @@ class CreateBoardBody(BaseModel):
|
|||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
default_workdir: Optional[str] = None
|
||||
# First-class Project (id or slug) to scope the board to. When set, the
|
||||
# board's default_workdir mirrors the project's primary repo and new tasks
|
||||
# inherit the project (deterministic worktree + branch).
|
||||
project_id: Optional[str] = None
|
||||
switch: bool = False
|
||||
|
||||
|
||||
|
|
@ -2089,6 +2226,38 @@ class RenameBoardBody(BaseModel):
|
|||
# Board-level default project directory for new tasks. ``None`` =
|
||||
# leave unchanged; empty string = clear; a path = validate + set.
|
||||
default_workdir: Optional[str] = None
|
||||
# Project scope (id or slug). ``None`` = leave unchanged; empty = clear;
|
||||
# a value = resolve + set (and mirror default_workdir to its primary repo).
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_project(ref: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve a project id/slug to ``(id, name, primary_path)``.
|
||||
|
||||
Returns ``(None, None, None)`` for a falsy ref. Raises 400 when a
|
||||
non-empty ref doesn't resolve to an existing project.
|
||||
"""
|
||||
if not ref or not ref.strip():
|
||||
return None, None, None
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
with pdb.connect_closing() as pconn:
|
||||
proj = pdb.get_project(pconn, ref.strip())
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"projects unavailable: {exc}")
|
||||
if proj is None:
|
||||
raise HTTPException(status_code=400, detail=f"project {ref!r} does not exist")
|
||||
return proj.id, proj.name, (proj.primary_path or None)
|
||||
|
||||
|
||||
def _projects_by_id() -> dict[str, Any]:
|
||||
"""Map every project id -> Project (archived included) for annotation."""
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
with pdb.connect_closing() as pconn:
|
||||
return {p.id: p for p in pdb.list_projects(pconn, include_archived=True)}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _board_counts(slug: str) -> dict[str, int]:
|
||||
|
|
@ -2120,16 +2289,54 @@ def _default_workspace_kind(board: dict[str, Any]) -> str:
|
|||
return "dir"
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_kanban_projects():
|
||||
"""List first-class Hermes projects for board scoping.
|
||||
|
||||
Returns ``{projects: [{id, slug, name, primary_path, icon, color}]}``.
|
||||
Archived projects are excluded — a board can only be scoped to a live one.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
with pdb.connect_closing() as pconn:
|
||||
projects = pdb.list_projects(pconn, include_archived=False)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to list projects: {exc}")
|
||||
return {
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"slug": p.slug,
|
||||
"name": p.name,
|
||||
"primary_path": p.primary_path or "",
|
||||
"icon": p.icon or "",
|
||||
"color": p.color or "",
|
||||
}
|
||||
for p in projects
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/boards")
|
||||
def list_boards(include_archived: bool = Query(False)):
|
||||
"""Return every board on disk with task counts and the active slug."""
|
||||
boards = kanban_db.list_boards(include_archived=include_archived)
|
||||
current = kanban_db.get_current_board()
|
||||
proj_map = _projects_by_id()
|
||||
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)
|
||||
pid = b.get("project_id") or None
|
||||
b["project_id"] = pid
|
||||
proj = proj_map.get(pid) if pid else None
|
||||
b["project_name"] = proj.name if proj else None
|
||||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
|
|
@ -2159,6 +2366,11 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
default_workdir = None
|
||||
if payload.default_workdir:
|
||||
default_workdir = _validate_workdir(payload.default_workdir)
|
||||
# A chosen project scopes the board: its primary repo becomes the default
|
||||
# workdir (unless one was passed explicitly) and the link is stored.
|
||||
project_id, _pname, primary_path = _resolve_project(payload.project_id)
|
||||
if primary_path and not default_workdir:
|
||||
default_workdir = primary_path
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
|
|
@ -2167,6 +2379,7 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
default_workdir=default_workdir,
|
||||
project_id=project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
|
@ -2176,6 +2389,7 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
meta["default_workspace_kind"] = _default_workspace_kind(meta)
|
||||
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
|
||||
return {"board": meta, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
|
|
@ -2194,6 +2408,17 @@ def rename_board(slug: str, payload: RenameBoardBody):
|
|||
if payload.default_workdir is not None:
|
||||
raw = payload.default_workdir.strip()
|
||||
default_workdir = _validate_workdir(raw) if raw else ""
|
||||
# project_id: None = leave; "" = clear; value = resolve + mirror its repo
|
||||
# into default_workdir (unless the caller set default_workdir explicitly).
|
||||
project_id: Optional[str] = None
|
||||
project_name: Optional[str] = None
|
||||
if payload.project_id is not None:
|
||||
if payload.project_id.strip():
|
||||
project_id, project_name, primary_path = _resolve_project(payload.project_id)
|
||||
if primary_path and default_workdir is None:
|
||||
default_workdir = primary_path
|
||||
else:
|
||||
project_id = "" # clear the scope
|
||||
meta = kanban_db.write_board_metadata(
|
||||
normed,
|
||||
name=payload.name,
|
||||
|
|
@ -2201,8 +2426,10 @@ def rename_board(slug: str, payload: RenameBoardBody):
|
|||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
default_workdir=default_workdir,
|
||||
project_id=project_id,
|
||||
)
|
||||
meta["default_workspace_kind"] = _default_workspace_kind(meta)
|
||||
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
|
||||
return {"board": meta}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3540,8 +3540,14 @@ class AIAgent:
|
|||
self._last_activity_desc = desc
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
try:
|
||||
from tools.kanban_tools import heartbeat_current_worker_from_env
|
||||
from tools.kanban_tools import (
|
||||
heartbeat_current_worker_from_env,
|
||||
inject_new_comments_from_env,
|
||||
)
|
||||
heartbeat_current_worker_from_env()
|
||||
# Fold any new operator notes into the running turn (OUT-OF-BAND
|
||||
# steer) so the user can talk to a live task without a restart.
|
||||
inject_new_comments_from_env(self)
|
||||
except Exception:
|
||||
# Never let the bridge break the agent loop. The function
|
||||
# already swallows exceptions internally; this outer guard
|
||||
|
|
|
|||
87
tests/hermes_cli/test_kanban_board_project.py
Normal file
87
tests/hermes_cli/test_kanban_board_project.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Board→project scoping in kanban_db.
|
||||
|
||||
A kanban board can be scoped to a first-class Hermes project so every task on
|
||||
it anchors to that project (deterministic worktree + branch). Covers the
|
||||
metadata round-trip and the create-time inheritance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_WORKTREE = Path(__file__).resolve().parents[2]
|
||||
if str(_WORKTREE) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKTREE))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
try:
|
||||
import hermes_constants
|
||||
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
return home
|
||||
|
||||
|
||||
def test_board_metadata_project_id_roundtrip(fresh_home):
|
||||
assert kb.read_board_metadata("default").get("project_id") is None
|
||||
|
||||
kb.write_board_metadata("default", project_id="p_abc123")
|
||||
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
|
||||
|
||||
# None leaves unchanged; "" clears.
|
||||
kb.write_board_metadata("default", name="Still Here")
|
||||
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
|
||||
kb.write_board_metadata("default", project_id="")
|
||||
assert kb.read_board_metadata("default")["project_id"] is None
|
||||
|
||||
|
||||
def test_create_board_accepts_project_id(fresh_home):
|
||||
meta = kb.create_board("proj-board", name="Proj Board", project_id="p_xyz")
|
||||
assert meta["project_id"] == "p_xyz"
|
||||
assert kb.read_board_metadata("proj-board")["project_id"] == "p_xyz"
|
||||
|
||||
|
||||
def test_create_task_inherits_board_project(fresh_home, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
with pdb.connect_closing() as pconn:
|
||||
proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo))
|
||||
|
||||
kb.create_board("scoped", name="Scoped", project_id=proj_id)
|
||||
conn = kb.connect(board="scoped")
|
||||
try:
|
||||
tid = kb.create_task(conn, title="inherit me", board="scoped")
|
||||
assert kb.get_task(conn, tid).project_id == proj_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_task_explicit_project_beats_board(fresh_home, tmp_path):
|
||||
(tmp_path / "a").mkdir()
|
||||
(tmp_path / "b").mkdir()
|
||||
with pdb.connect_closing() as pconn:
|
||||
board_proj = pdb.create_project(pconn, name="BoardProj", primary_path=str(tmp_path / "a"))
|
||||
task_proj = pdb.create_project(pconn, name="TaskProj", primary_path=str(tmp_path / "b"))
|
||||
|
||||
kb.create_board("scoped2", name="Scoped2", project_id=board_proj)
|
||||
conn = kb.connect(board="scoped2")
|
||||
try:
|
||||
tid = kb.create_task(conn, title="explicit", board="scoped2", project_id=task_proj)
|
||||
assert kb.get_task(conn, tid).project_id == task_proj
|
||||
finally:
|
||||
conn.close()
|
||||
54
tests/hermes_cli/test_kanban_comment_queries.py
Normal file
54
tests/hermes_cli/test_kanban_comment_queries.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Comment-watermark queries in kanban_db.
|
||||
|
||||
``list_comments_after`` backs the live worker bridge: it returns only comments
|
||||
newer than a cursor so a running worker folds in new operator notes without
|
||||
re-reading the whole thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_WORKTREE = Path(__file__).resolve().parents[2]
|
||||
if str(_WORKTREE) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKTREE))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
try:
|
||||
import hermes_constants
|
||||
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
return home
|
||||
|
||||
|
||||
def test_list_comments_after_cursor(fresh_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="chat")
|
||||
c1 = kb.add_comment(conn, tid, author="alice", body="first")
|
||||
c2 = kb.add_comment(conn, tid, author="bob", body="second")
|
||||
|
||||
assert [c.id for c in kb.list_comments_after(conn, tid, after_id=0)] == [c1, c2]
|
||||
|
||||
newer = kb.list_comments_after(conn, tid, after_id=c1)
|
||||
assert [c.id for c in newer] == [c2]
|
||||
assert newer[0].body == "second"
|
||||
|
||||
assert kb.list_comments_after(conn, tid, after_id=c2) == []
|
||||
finally:
|
||||
conn.close()
|
||||
119
tests/plugins/test_kanban_board_project_api.py
Normal file
119
tests/plugins/test_kanban_board_project_api.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Kanban dashboard plugin: project listing + project-scoped boards.
|
||||
|
||||
Attaches the plugin router to a bare FastAPI app (as in
|
||||
test_kanban_dashboard_plugin.py) and exercises the project surface:
|
||||
GET /projects, board create/patch/list carrying project scope, and a task
|
||||
on a scoped board inheriting the project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_proj_test", plugin_file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path):
|
||||
repo = tmp_path / "widget-repo"
|
||||
repo.mkdir()
|
||||
with pdb.connect_closing() as conn:
|
||||
pid = pdb.create_project(conn, name="Widget", primary_path=str(repo))
|
||||
return {"id": pid, "primary_path": str(repo)}
|
||||
|
||||
|
||||
def test_list_projects(client, project):
|
||||
r = client.get("/api/plugins/kanban/projects")
|
||||
assert r.status_code == 200
|
||||
hit = next(p for p in r.json()["projects"] if p["id"] == project["id"])
|
||||
assert hit["name"] == "Widget"
|
||||
assert hit["primary_path"] == project["primary_path"]
|
||||
|
||||
|
||||
def test_create_board_with_project_mirrors_workdir(client, project):
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/boards",
|
||||
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
board = r.json()["board"]
|
||||
assert board["project_id"] == project["id"]
|
||||
assert board["project_name"] == "Widget"
|
||||
assert board["default_workdir"] == project["primary_path"]
|
||||
|
||||
|
||||
def test_create_board_rejects_unknown_project(client):
|
||||
r = client.post("/api/plugins/kanban/boards", json={"slug": "bad", "project_id": "p_nope"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_patch_board_set_and_clear_project(client, project):
|
||||
client.post("/api/plugins/kanban/boards", json={"slug": "widget", "name": "Widget"})
|
||||
|
||||
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": project["id"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["board"]["project_id"] == project["id"]
|
||||
|
||||
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": ""})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["board"]["project_id"] is None
|
||||
|
||||
|
||||
def test_boards_list_surfaces_project(client, project):
|
||||
client.post(
|
||||
"/api/plugins/kanban/boards",
|
||||
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
|
||||
)
|
||||
widget = next(b for b in client.get("/api/plugins/kanban/boards").json()["boards"] if b["slug"] == "widget")
|
||||
assert widget["project_id"] == project["id"]
|
||||
assert widget["project_name"] == "Widget"
|
||||
|
||||
|
||||
def test_task_on_scoped_board_inherits_project(client, project):
|
||||
client.post(
|
||||
"/api/plugins/kanban/boards",
|
||||
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
|
||||
)
|
||||
r = client.post("/api/plugins/kanban/tasks?board=widget", json={"title": "do the thing"})
|
||||
assert r.status_code == 200, r.text
|
||||
task_id = r.json()["task"]["id"]
|
||||
|
||||
conn = kb.connect(board="widget")
|
||||
try:
|
||||
assert kb.get_task(conn, task_id).project_id == project["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
102
tests/plugins/test_kanban_estimate.py
Normal file
102
tests/plugins/test_kanban_estimate.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Kanban dashboard plugin: task effort estimate.
|
||||
|
||||
The estimate endpoints call the auto-routed auxiliary model and parse a
|
||||
compact JSON reply (tokens + complexity + rationale). Tests monkeypatch
|
||||
``call_llm`` so no network is touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_est_test", plugin_file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _fake_resp(content: str, model: str = "aux-mini"):
|
||||
msg = types.SimpleNamespace(content=content)
|
||||
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)], model=model)
|
||||
|
||||
|
||||
def test_estimate_parses_model_json(client, monkeypatch):
|
||||
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "big refactor"}).json()["task"]["id"]
|
||||
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
assert kwargs.get("task") == "kanban_estimator"
|
||||
return _fake_resp('{"est_tokens": 42000, "complexity": "M", "rationale": "multi-file edit"}')
|
||||
|
||||
monkeypatch.setattr(aux, "call_llm", fake_call_llm)
|
||||
|
||||
body = client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()
|
||||
assert body["ok"] is True
|
||||
assert body["est_tokens"] == 42000
|
||||
assert body["complexity"] == "M"
|
||||
assert body["rationale"] == "multi-file edit"
|
||||
assert body["model"] == "aux-mini"
|
||||
|
||||
|
||||
def test_estimate_tolerates_unparseable_reply(client, monkeypatch):
|
||||
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "vague"}).json()["task"]["id"]
|
||||
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(aux, "call_llm", lambda **kw: _fake_resp("I cannot estimate this, sorry."))
|
||||
|
||||
assert client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()["ok"] is False
|
||||
|
||||
|
||||
def test_estimate_unknown_task_404(client):
|
||||
assert client.post("/api/plugins/kanban/tasks/t_missing/estimate").status_code == 404
|
||||
|
||||
|
||||
def test_estimate_from_text_no_task(client, monkeypatch):
|
||||
"""The create dialog estimates from typed title/body before a task exists."""
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(
|
||||
aux, "call_llm",
|
||||
lambda **kw: _fake_resp('{"est_tokens": 8000, "complexity": "S", "rationale": "localized"}'),
|
||||
)
|
||||
body = client.post(
|
||||
"/api/plugins/kanban/estimate", json={"title": "tweak a label", "body": "in settings"}
|
||||
).json()
|
||||
assert body["ok"] is True
|
||||
assert body["est_tokens"] == 8000
|
||||
assert body["complexity"] == "S"
|
||||
|
||||
|
||||
def test_estimate_from_text_requires_title(client):
|
||||
assert client.post("/api/plugins/kanban/estimate", json={"title": " ", "body": "x"}).json()["ok"] is False
|
||||
124
tests/tools/test_kanban_comment_injection.py
Normal file
124
tests/tools/test_kanban_comment_injection.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Live operator-note injection into a running kanban worker.
|
||||
|
||||
``tools.kanban_tools.inject_new_comments_from_env`` polls the worker's task
|
||||
for comments added *after* the run started and folds them into the live turn
|
||||
via the agent's OUT-OF-BAND steer channel — so a user can talk to a running
|
||||
task without the block→comment→unblock dance or a restart.
|
||||
|
||||
Verifies: no-op off a worker, watermark seeding (history isn't re-injected),
|
||||
new comments steer, and own-authored comments are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_WORKTREE = Path(__file__).resolve().parents[2]
|
||||
if str(_WORKTREE) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKTREE))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
import tools.kanban_tools as kt
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.steers: list[str] = []
|
||||
|
||||
def steer(self, text: str) -> bool:
|
||||
self.steers.append(text)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
try:
|
||||
import hermes_constants
|
||||
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
# Reset module-level poll state so tests don't leak into each other.
|
||||
kt._comment_watermark.clear()
|
||||
kt._comment_poll_last_attempt = 0.0
|
||||
return home
|
||||
|
||||
|
||||
def _unthrottle():
|
||||
"""Bypass the inter-poll rate limit for deterministic tests."""
|
||||
kt._comment_poll_last_attempt = 0.0
|
||||
|
||||
|
||||
def test_noop_without_worker_env(worker_home, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
agent = FakeAgent()
|
||||
assert kt.inject_new_comments_from_env(agent) is False
|
||||
assert agent.steers == []
|
||||
|
||||
|
||||
def test_seed_then_inject_new_comment(worker_home, monkeypatch):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="live task")
|
||||
kb.add_comment(conn, tid, author="desktop", body="pre-existing note")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
|
||||
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
|
||||
agent = FakeAgent()
|
||||
|
||||
# First poll seeds the watermark past the existing thread — no injection.
|
||||
_unthrottle()
|
||||
assert kt.inject_new_comments_from_env(agent) is False
|
||||
assert agent.steers == []
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
kb.add_comment(conn, tid, author="desktop", body="actually use the v2 API")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_unthrottle()
|
||||
assert kt.inject_new_comments_from_env(agent) is True
|
||||
assert len(agent.steers) == 1
|
||||
assert "v2 API" in agent.steers[0]
|
||||
|
||||
# Watermark advanced — a re-poll with no new comments injects nothing.
|
||||
_unthrottle()
|
||||
assert kt.inject_new_comments_from_env(agent) is False
|
||||
assert len(agent.steers) == 1
|
||||
|
||||
|
||||
def test_skips_own_authored_comments(worker_home, monkeypatch):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="echo guard")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
|
||||
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
|
||||
agent = FakeAgent()
|
||||
|
||||
_unthrottle()
|
||||
kt.inject_new_comments_from_env(agent) # seed
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
kb.add_comment(conn, tid, author="worker-bot", body="i did a thing")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_unthrottle()
|
||||
assert kt.inject_new_comments_from_env(agent) is False
|
||||
assert agent.steers == []
|
||||
|
|
@ -320,6 +320,85 @@ def heartbeat_current_worker_from_env() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Live operator-note injection: poll the worker's task for new comments and
|
||||
# fold them into the running agent via the OUT-OF-BAND steer channel, so a user
|
||||
# can "talk to" a running kanban task without the block → comment → unblock
|
||||
# dance (or a restart). Rate-limited on its own (tighter than the 60s heartbeat
|
||||
# so notes land within a few seconds), watermarked per task id.
|
||||
_COMMENT_POLL_MIN_INTERVAL_SECONDS = 6.0
|
||||
_comment_poll_last_attempt: float = 0.0
|
||||
# task_id -> highest comment id already seen (seeded on first poll so history
|
||||
# already present in build_worker_context isn't re-injected).
|
||||
_comment_watermark: dict[str, int] = {}
|
||||
|
||||
|
||||
def inject_new_comments_from_env(agent: Any) -> bool:
|
||||
"""Fold new operator comments on the current worker's task into ``agent``.
|
||||
|
||||
Best-effort and self-gating: no-op unless this process is a kanban worker
|
||||
(``HERMES_KANBAN_TASK`` set) and ``agent`` exposes ``steer``. Returns True
|
||||
if a steer was injected, else False. Never raises into the agent loop.
|
||||
|
||||
The first poll only *seeds* the watermark to the newest existing comment —
|
||||
those are already in the worker's context — so only comments added after
|
||||
the run started are injected. The worker's own authored comments (matched
|
||||
by ``HERMES_PROFILE``) are skipped to avoid echoing itself.
|
||||
"""
|
||||
tid = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if not tid or agent is None or not hasattr(agent, "steer"):
|
||||
return False
|
||||
global _comment_poll_last_attempt
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
if (now - _comment_poll_last_attempt) < _COMMENT_POLL_MIN_INTERVAL_SECONDS:
|
||||
return False
|
||||
_comment_poll_last_attempt = now
|
||||
|
||||
seen = _comment_watermark.get(tid)
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
rows = kb.list_comments_after(conn, tid, after_id=seen or 0)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("comment-inject: bridge failed", exc_info=True)
|
||||
return False
|
||||
|
||||
if seen is None:
|
||||
# First poll for this task: seed past the existing thread, inject nothing.
|
||||
_comment_watermark[tid] = max((c.id for c in rows), default=0)
|
||||
return False
|
||||
if not rows:
|
||||
return False
|
||||
|
||||
# Advance the watermark past everything we just read (including our own
|
||||
# notes) so nothing is re-injected next poll.
|
||||
_comment_watermark[tid] = max(c.id for c in rows)
|
||||
|
||||
own = (os.environ.get("HERMES_PROFILE") or "").strip()
|
||||
fresh = [c for c in rows if (c.author or "").strip() != own and (c.body or "").strip()]
|
||||
if not fresh:
|
||||
return False
|
||||
|
||||
lines = [f"- {c.author or 'operator'}: {c.body.strip()}" for c in fresh]
|
||||
note = (
|
||||
"New note"
|
||||
+ ("s" if len(fresh) > 1 else "")
|
||||
+ " on your kanban task from the operator (delivered mid-run). "
|
||||
+ "Take it into account for the work you're doing right now:\n"
|
||||
+ "\n".join(lines)
|
||||
)
|
||||
try:
|
||||
return bool(agent.steer(note))
|
||||
except Exception:
|
||||
logger.debug("comment-inject: steer failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _ok(**fields: Any) -> str:
|
||||
return json.dumps({"ok": True, **fields})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue