i18n(desktop): localize the kanban plugin across all four locales

Every user-facing string in the kanban plugin now routes through useI18n
(new t.kanban namespace) instead of hardcoded English — en, ja, zh, zh-hant
in lockstep (typecheck enforces parity). Column labels/help move out of the
COLUMN_META const (visual-only now) into i18n via columnLabel/columnHelp;
LOCKED_COLUMNS/ARC_TITLES/complexity copy likewise. Matches the rest of the
desktop app, which is fully localized.
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:45:08 -04:00
parent 901205420f
commit f6c8f35a4a
12 changed files with 1174 additions and 282 deletions

View file

@ -2953,5 +2953,191 @@ export const en: Translations = {
description: 'Displays the mobile sidebar.',
toggle: open => `${open ? 'Show' : 'Hide'} sidebar`
}
},
kanban: {
nav: 'Kanban',
openBoard: 'Kanban: Open board',
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'
}
}

View file

@ -2798,5 +2798,191 @@ export const ja = defineLocale({
description: 'モバイルサイドバーを表示します。',
toggle: open => `サイドバーを${open ? '表示' : '非表示'}`
}
},
kanban: {
nav: 'カンバン',
openBoard: 'カンバン: ボードを開く',
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: '自動'
}
})

View file

@ -2512,4 +2512,176 @@ export interface Translations {
toggle: (open: boolean) => string
}
}
kanban: {
nav: string
openBoard: 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
}
}

View file

@ -2684,5 +2684,188 @@ export const zhHant = defineLocale({
description: '顯示行動裝置側邊欄。',
toggle: open => `${open ? '顯示' : '隱藏'}側邊欄`
}
},
kanban: {
nav: '看板',
openBoard: '看板:開啟面板',
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: '自動'
}
})

View file

@ -3114,5 +3114,188 @@ export const zh: Translations = {
description: '显示移动端侧边栏。',
toggle: open => `${open ? '显示' : '隐藏'}侧边栏`
}
},
kanban: {
nav: '看板',
openBoard: '看板:打开面板',
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: '自动'
}
}

View file

@ -34,7 +34,7 @@ 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 } from './ui'
import { errText, FIELD_LABEL, useKanban } from './ui'
const NO_PROJECT = '__none__'
@ -42,18 +42,19 @@ const NO_PROJECT = '__none__'
* 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}>Project</span>
<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}>No project (scratch sandboxes)</SelectItem>
<SelectItem value={NO_PROJECT}>{k.noProject}</SelectItem>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
{project.name}
@ -62,14 +63,15 @@ function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; va
</SelectContent>
</Select>
<span className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">
New tasks run in the project’s repo (a worktree per task); each task can still override its workspace at
creation. Manage projects with <span className="font-mono">hermes project</span>.
{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('')
@ -101,28 +103,28 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean
<Dialog onOpenChange={o => !o && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New board</DialogTitle>
<DialogTitle>{k.newBoard}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>Name</span>
<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="Board name"
placeholder={k.boardNamePlaceholder}
value={name}
/>
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {slug}</span>}
{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">
Cancel
{k.cancel}
</Button>
<Button disabled={!slug || create.isPending} onClick={() => create.mutate()}>
Create board
{k.createBoard}
</Button>
</DialogFooter>
</DialogContent>
@ -131,6 +133,7 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean
}
function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onClose: () => void }) {
const k = useKanban()
const qc = useQueryClient()
const [name, setName] = useState('')
const [project, setProject] = useState('')
@ -157,22 +160,22 @@ function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onCl
<Dialog onOpenChange={o => !o && onClose()} open={Boolean(board)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Board settings{board ? ` — ${board.name || board.slug}` : ''}</DialogTitle>
<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}>Name</span>
<Input onChange={event => setName(event.target.value)} placeholder="Board name" value={name} />
{board && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {board.slug}</span>}
<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">
Cancel
{k.cancel}
</Button>
<Button disabled={save.isPending} onClick={() => save.mutate()}>
Save
{k.save}
</Button>
</DialogFooter>
</DialogContent>
@ -181,6 +184,7 @@ function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onCl
}
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)
@ -192,7 +196,7 @@ export function BoardSwitcher() {
const currentSlug = slug || boards.current
const current = boards.boards.find(meta => meta.slug === currentSlug)
const label = current?.name || current?.slug || 'Board'
const label = current?.name || current?.slug || k.board
return (
<>
@ -223,12 +227,12 @@ export function BoardSwitcher() {
{current && (
<DropdownMenuItem onSelect={() => setSettingsFor(current)}>
<Codicon name="settings-gear" size="0.8rem" />
Board settings…
{k.boardSettings}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setAdding(true)}>
<Codicon name="add" size="0.8rem" />
New board…
{k.newBoardDots}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -79,20 +79,22 @@ import {
import { BoardSwitcher } from './board-switcher'
import { TaskDrawer } from './drawer'
import { OrchestrationPanel } from './orchestration'
import { columnMeta, COMPLEXITY_LABEL, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
import {
ago,
ARC_TITLES,
type ArcState,
arcState,
Avatar,
columnHelp,
columnLabel,
errText,
FIELD_LABEL,
isLockedTarget,
LOCKED_COLUMNS,
lockedReason,
RunClock,
shortId,
useDefaultAssignee,
useKanban,
useOrchestration
} from './ui'
@ -140,6 +142,7 @@ function Meta({ children, icon }: { children: ReactNode; icon: string }) {
}
function CardFooter({ arc, task }: { arc: ArcState | null; task: KanbanTask }) {
const k = useKanban()
const created = ago(task.created_at)
const links = task.link_counts ? task.link_counts.parents + task.link_counts.children : 0
const fallback = useDefaultAssignee()
@ -163,12 +166,12 @@ function CardFooter({ arc, task }: { arc: ArcState | null; task: KanbanTask }) {
<Tip
label={
task.status === 'review'
? 'A review agent is checking the completed work.'
? k.reviewChecking
: task.assignee
? `${attached} is attached — the dispatcher hands this over on its next tick (≤1m).`
? k.attachedTip(attached)
: task.status === 'triage'
? `${attached} (the orchestrator) picks this up on the next tick and writes the spec.`
: `Auto-assigns to “${attached}” (kanban.default_assignee) on the next dispatch tick.`
? k.orchestratorTip(attached)
: k.autoAssignTip(attached)
}
>
<span className="inline-flex min-w-0 cursor-help items-center gap-1 font-medium" style={{ color: meta.tone }}>
@ -183,22 +186,22 @@ function CardFooter({ arc, task }: { arc: ArcState | null; task: KanbanTask }) {
<Avatar name={task.assignee} size="1.125rem" />
) : null}
{arc === 'running' && (
<Tip label={ARC_TITLES.running}>
<Tip label={k.arcRunning}>
<span className="shrink-0 cursor-help">
<RunClock task={task} />
</span>
</Tip>
)}
{arc === 'stale' && (
<Tip label={ARC_TITLES.stale}>
<span className="shrink-0 cursor-help font-medium text-amber-500">no heartbeat</span>
<Tip label={k.arcStale}>
<span className="shrink-0 cursor-help font-medium text-amber-500">{k.noHeartbeat}</span>
</Tip>
)}
{unassignedReady && !fallback && (
<Tip label="Ready cards only run once a profile is assigned. Open the card and set an assignee, or configure a default assignee in orchestration settings.">
<Tip label={k.wontRunTip}>
<span className="inline-flex shrink-0 cursor-help items-center gap-1 text-amber-500">
<Codicon name="debug-disconnect" size="0.7rem" />
won't run
{k.wontRun}
</span>
</Tip>
)}
@ -248,6 +251,7 @@ function Card({
selected: boolean
task: KanbanTask
}) {
const k = useKanban()
const [dragging, setDragging] = useState(false)
const meta = columnMeta(task.status)
const summary = task.latest_summary || task.body
@ -299,11 +303,11 @@ function Card({
<ContextMenuContent>
<ContextMenuItem onSelect={() => onOpen(task.id)}>
<Codicon name="link-external" size="0.85rem" />
Open
{k.open}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onToggleSelect(task.id)}>
<Codicon name={selected ? 'close' : 'check-all'} size="0.85rem" />
{selected ? 'Deselect' : 'Select (⌘-click)'}
{selected ? k.deselect : k.select}
</ContextMenuItem>
<ContextMenuSeparator />
{columns
@ -311,13 +315,13 @@ function Card({
.map(name => (
<ContextMenuItem key={name} onSelect={() => onMove(task.id, name)}>
<span className="size-2 rounded-full" style={{ backgroundColor: columnMeta(name).tone }} />
Move to {columnMeta(name).label}
{k.moveTo(columnLabel(k, name))}
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onDelete(task.id)} variant="destructive">
<Codicon name="trash" size="0.85rem" />
Delete
{k.delete}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@ -351,8 +355,10 @@ function Column({
onToggleSelect: (id: string) => void
selected: ReadonlySet<string>
}) {
const k = useKanban()
const [over, setOver] = useState(false)
const meta = columnMeta(column.name)
const label = columnLabel(k, column.name)
const locked = isLockedTarget(column.name)
const byProfile = useValue($lanesByProfile)
@ -409,7 +415,7 @@ function Column({
return (
<button
{...dragHandlers}
aria-label={`Expand ${meta.label}`}
aria-label={k.expand(label)}
className={cn(
'flex h-full w-8 shrink-0 flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-(--ui-bg-quinary)',
wash
@ -421,7 +427,7 @@ function Column({
<span className="size-1.5 rounded-full" style={{ backgroundColor: meta.tone }} />
</span>
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary) [writing-mode:vertical-rl]">
{meta.label}
{label}
</span>
{column.tasks.length > 0 && (
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">{column.tasks.length}</span>
@ -437,14 +443,14 @@ function Column({
>
<header className="mb-1.5 flex h-5 items-center gap-1.5 px-1">
<span className="size-1.5 rounded-full" style={{ backgroundColor: meta.tone }} />
<Tip label={meta.help}>
<Tip label={columnHelp(k, column.name)}>
<span className="cursor-help text-[0.6875rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
{meta.label}
{label}
</span>
</Tip>
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">{column.tasks.length}</span>
<button
aria-label={`Collapse ${meta.label}`}
aria-label={k.collapse(label)}
className="ml-auto grid size-5 place-items-center rounded text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:opacity-100 group-hover/col:opacity-100"
onClick={onToggle}
type="button"
@ -492,7 +498,7 @@ function Column({
Locked lanes get none: you can't create into a system state. */}
{!locked && (
<button
aria-label={`New task in ${meta.label}`}
aria-label={k.newTaskIn(label)}
className="flex shrink-0 items-center justify-center rounded-md border border-dashed border-(--ui-stroke-secondary) py-1.5 text-(--ui-text-tertiary) opacity-0 transition-[opacity,color,border-color] group-hover/col:opacity-100 hover:border-(--ui-text-quaternary) hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:opacity-100"
onClick={() => onAdd(column.name)}
type="button"
@ -502,7 +508,7 @@ function Column({
)}
{column.tasks.length === 0 && (
<div className="pointer-events-none absolute inset-0 grid place-items-center text-[0.6875rem] text-(--ui-text-quaternary)">
Empty
{k.empty}
</div>
)}
</div>
@ -534,6 +540,7 @@ function NewTaskDialog({
parents: Array<{ id: string; title: string }>
target: null | string
}) {
const k = useKanban()
const qc = useQueryClient()
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
// Title-only creates must RUN: "auto" resolves to the orchestration default
@ -576,7 +583,7 @@ function NewTaskDialog({
if (r.ok) {
setEstimate(r)
} else {
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
host.notify({ kind: 'warning', message: r.reason || k.couldNotEstimate })
}
}
})
@ -655,7 +662,7 @@ function NewTaskDialog({
<Dialog onOpenChange={open => !open && onClose()} open={Boolean(target)}>
<DialogContent className="w-[min(42rem,94vw)] max-w-none">
<DialogHeader>
<DialogTitle>New task{target ? ` in ${columnMeta(target).label}` : ''}</DialogTitle>
<DialogTitle>{target ? k.newTaskIn(columnLabel(k, target)) : k.newTask}</DialogTitle>
</DialogHeader>
<div className="flex max-h-[min(72vh,44rem)] flex-col gap-3 overflow-y-auto pr-0.5">
<Input
@ -667,21 +674,21 @@ function NewTaskDialog({
void submit()
}
}}
placeholder={isTriage ? 'Rough idea — a specifier will flesh it out' : 'Title'}
placeholder={isTriage ? k.titlePlaceholderTriage : k.titlePlaceholder}
value={title}
/>
<Textarea
className="min-h-20"
onChange={event => setBodyText(event.target.value)}
placeholder="Description (optional)"
placeholder={k.descPlaceholder}
value={bodyText}
/>
<div className="grid grid-cols-2 gap-3">
<Field label="Priority">
<Field label={k.priority}>
<Input onChange={event => setPriority(event.target.value)} type="number" value={priority} />
</Field>
<Field label="Workspace">
<Field label={k.workspace}>
<Select onValueChange={setWorkspaceKind} value={workspaceKind}>
<SelectTrigger>
<SelectValue />
@ -690,7 +697,7 @@ function NewTaskDialog({
{WORKSPACE_KINDS.map(kind => (
<SelectItem key={kind} value={kind}>
{kind}
{kind === boardDefaultKind ? ' · board default' : ''}
{kind === boardDefaultKind ? k.boardDefaultSuffix : ''}
</SelectItem>
))}
</SelectContent>
@ -699,27 +706,25 @@ function NewTaskDialog({
</div>
{workspaceKind !== 'scratch' && (
<Field label="Workspace path (optional override)">
<Field label={k.workspaceOverride}>
<Input
onChange={event => setWorkspacePath(event.target.value)}
placeholder={boardDefaultDir || 'Inherits the board’s project directory'}
placeholder={boardDefaultDir || k.workspaceInherit}
value={workspacePath}
/>
<span className="text-[0.625rem] text-(--ui-text-quaternary)">
{boardDefaultDir
? `Leave empty to inherit ${boardDefaultDir}`
: 'Leave empty to inherit the board’s project directory.'}
{boardDefaultDir ? k.workspaceInheritDir(boardDefaultDir) : k.workspaceInheritGeneric}
</span>
</Field>
)}
<Field label="Assignee">
<Field label={k.assignee}>
<Select onValueChange={v => setAssignee(v === NO_PARENT ? '' : v)} value={assignee || NO_PARENT}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PARENT}>{resolvedDefault} (default)</SelectItem>
<SelectItem value={NO_PARENT}>{k.defaultOption(resolvedDefault)}</SelectItem>
{(roster?.profiles ?? [])
.filter(profile => profile.name !== resolvedDefault)
.map(profile => (
@ -727,23 +732,23 @@ function NewTaskDialog({
{profile.name}
</SelectItem>
))}
<SelectItem value={PARKED}>unassigned (parked — won't run)</SelectItem>
<SelectItem value={PARKED}>{k.parkedOption}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Skills (comma-separated)">
<Input onChange={event => setSkills(event.target.value)} placeholder="translation, github" value={skills} />
<Field label={k.skills}>
<Input onChange={event => setSkills(event.target.value)} placeholder={k.skillsPlaceholder} value={skills} />
</Field>
{parents.length > 0 && (
<Field label="Parent (blocks until it's done)">
<Field label={k.parent}>
<Select onValueChange={v => setParent(v === NO_PARENT ? '' : v)} value={parent || NO_PARENT}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PARENT}>— no parent —</SelectItem>
<SelectItem value={NO_PARENT}>{k.noParent}</SelectItem>
{parents.map(option => (
<SelectItem key={option.id} value={option.id}>
{option.title || option.id}
@ -755,8 +760,8 @@ function NewTaskDialog({
)}
<label className="flex cursor-pointer items-center gap-2 text-[0.75rem] text-(--ui-text-secondary)">
<Switch aria-label="Goal mode" checked={goalMode} onCheckedChange={setGoalMode} size="xs" />
Goal mode (worker loops until a judge agrees it's done)
<Switch aria-label={k.goalMode} checked={goalMode} onCheckedChange={setGoalMode} size="xs" />
{k.goalMode}
</label>
{error && <span className="text-[0.75rem] text-destructive">{error}</span>}
@ -765,15 +770,15 @@ function NewTaskDialog({
<div className="mr-auto flex items-center gap-1 text-[0.75rem] text-(--ui-text-tertiary)">
{estimate?.ok ? (
<>
<Tip label={estimate.rationale || 'Rough estimate'}>
<Tip label={estimate.rationale || k.roughEstimate}>
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
~{compactNumber(estimate.est_tokens)} tok
{estimate.complexity ? ` · ${COMPLEXITY_LABEL[estimate.complexity] ?? estimate.complexity}` : ''}
~{compactNumber(estimate.est_tokens)} {k.tokUnit}
{estimate.complexity ? ` · ${k.complexity[estimate.complexity] ?? estimate.complexity}` : ''}
</span>
</Tip>
<Tip label="Re-estimate">
<Tip label={k.reEstimate}>
<Button
aria-label="Re-estimate"
aria-label={k.reEstimate}
disabled={!title.trim() || estMut.isPending}
onClick={() => estMut.mutate()}
size="icon-xs"
@ -784,7 +789,7 @@ function NewTaskDialog({
</Tip>
</>
) : (
<Tip label="Rough token + complexity estimate from the auxiliary model — makes a model call.">
<Tip label={k.estimateTip}>
<Button
disabled={!title.trim() || estMut.isPending}
onClick={() => estMut.mutate()}
@ -792,16 +797,16 @@ function NewTaskDialog({
variant="ghost"
>
<Codicon name={estMut.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={estMut.isPending} />
{estMut.isPending ? 'Estimating…' : 'Estimate'}
{estMut.isPending ? k.estimating : k.estimate}
</Button>
</Tip>
)}
</div>
<Button onClick={onClose} variant="text">
Cancel
{k.cancel}
</Button>
<Button disabled={!title.trim() || busy} onClick={() => void submit()}>
{busy ? 'Creating…' : 'Create task'}
{busy ? k.creating : k.createTask}
</Button>
</DialogFooter>
</DialogContent>
@ -814,6 +819,7 @@ function NewTaskDialog({
// One-time explainer for the board's core gotcha: this is a dispatcher queue,
// not a todo list. Dismissal persists via plugin storage.
function Intro() {
const k = useKanban()
const dismissed = useValue($introDismissed)
if (dismissed) {
@ -825,14 +831,9 @@ function Intro() {
className="mx-4 mb-2 flex flex-col items-start gap-1.5 rounded-lg bg-(--ui-bg-quinary) px-3 py-2.5 text-[0.75rem] leading-relaxed text-(--ui-text-secondary)"
data-selectable-text="true"
>
<p className="min-w-0">
You don't run the cards — agents do. Put a card in <b>Ready</b> with an assignee and an agent picks it up within
a minute. No assignee, no run. <b>Triage</b>: an agent rewrites the idea into a proper task first. <b>Todo</b>:
waiting on other cards. <b>Scheduled</b>: waiting on a timer. <b>Running</b> and <b>Review</b>: the agents'
lanes, hands off. <b>Blocked</b>: it's waiting on you. Results come back on the card.
</p>
<p className="min-w-0">{k.introBody}</p>
<Button onClick={() => $introDismissed.set(true)} size="inline" variant="textStrong">
Got it
{k.introGotIt}
</Button>
</div>
)
@ -859,6 +860,7 @@ function FilterMenu({
onTenant: (v: string) => void
tenant: string
}) {
const k = useKanban()
const active = Boolean(assignee || tenant || archived)
const lanesByProfile = useValue($lanesByProfile)
@ -868,7 +870,7 @@ function FilterMenu({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Filters"
aria-label={k.filters}
className={cn(active && 'bg-(--ui-control-active-background) text-foreground')}
size="icon-xs"
variant="ghost"
@ -877,7 +879,10 @@ function FilterMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => onAssignee('')}>All profiles{check(!assignee)}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAssignee('')}>
{k.allProfiles}
{check(!assignee)}
</DropdownMenuItem>
{board.assignees.map(name => (
<DropdownMenuItem key={name} onSelect={() => onAssignee(name)}>
<Avatar name={name} size="0.875rem" />
@ -888,7 +893,10 @@ function FilterMenu({
{board.tenants.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onTenant('')}>All tenants{check(!tenant)}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onTenant('')}>
{k.allTenants}
{check(!tenant)}
</DropdownMenuItem>
{board.tenants.map(name => (
<DropdownMenuItem key={name} onSelect={() => onTenant(name)}>
{name}
@ -898,9 +906,13 @@ function FilterMenu({
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onArchived(!archived)}>Show archived{check(archived)}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onArchived(!archived)}>
{k.showArchived}
{check(archived)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => $lanesByProfile.set(!lanesByProfile)}>
Group Running by profile{check(lanesByProfile)}
{k.groupRunning}
{check(lanesByProfile)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -927,6 +939,7 @@ function SelectionBar({
onDone: (failed: string[]) => void
selected: ReadonlySet<string>
}) {
const k = useKanban()
const qc = useQueryClient()
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
@ -936,7 +949,7 @@ function SelectionBar({
if (failed.length > 0) {
host.notify({
kind: 'warning',
message: `${failed.length} of ${selected.size} failed — ${failed[0].error ?? 'refused'}. Failed cards stay selected.`
message: k.bulkFailed(failed.length, selected.size, failed[0].error ?? k.refused)
})
}
@ -973,12 +986,12 @@ function SelectionBar({
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center px-4">
{/* Flat overlay: stroke + elevated surface do the separating, no shadow. */}
<div className="pointer-events-auto flex items-center gap-1 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) py-1 pr-1 pl-3">
<span className="mr-1 text-xs tabular-nums text-(--ui-text-secondary)">{selected.size} selected</span>
<span className="mr-1 text-xs tabular-nums text-(--ui-text-secondary)">{k.nSelected(selected.size)}</span>
<DropdownMenu onOpenChange={open => setMenu(open ? 'move' : null)} open={menu === 'move'}>
<DropdownMenuTrigger asChild>
<Button disabled={busy} size="xs" variant="ghost">
Move to
{k.moveToShort}
<Codicon name="chevron-down" size="0.7rem" />
</Button>
</DropdownMenuTrigger>
@ -988,7 +1001,7 @@ function SelectionBar({
.map(name => (
<DropdownMenuItem key={name} onSelect={() => bulk.mutate({ status: name })}>
<span className="size-2 rounded-full" style={{ backgroundColor: columnMeta(name).tone }} />
{columnMeta(name).label}
{columnLabel(k, name)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -997,7 +1010,7 @@ function SelectionBar({
<DropdownMenu onOpenChange={open => setMenu(open ? 'assign' : null)} open={menu === 'assign'}>
<DropdownMenuTrigger asChild>
<Button disabled={busy} size="xs" variant="ghost">
Assign
{k.assign}
<Codicon name="chevron-down" size="0.7rem" />
</Button>
</DropdownMenuTrigger>
@ -1013,13 +1026,13 @@ function SelectionBar({
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => bulk.mutate({ assignee: '', reclaim_first: true })}>
Unassign
{k.unassignAction}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button disabled={busy} onClick={() => bulk.mutate({ archive: true })} size="xs" variant="ghost">
Archive
{k.archive}
</Button>
<Button
className="text-destructive"
@ -1028,11 +1041,11 @@ function SelectionBar({
size="xs"
variant="ghost"
>
Delete
{k.delete}
</Button>
<Tip label="Clear selection (Esc)">
<Button aria-label="Clear selection" onClick={onClear} size="icon-xs" variant="ghost">
<Tip label={k.clearSelection}>
<Button aria-label={k.clearSelection} onClick={onClear} size="icon-xs" variant="ghost">
<Codicon name="close" size="0.8rem" />
</Button>
</Tip>
@ -1044,6 +1057,7 @@ function SelectionBar({
// ── page ─────────────────────────────────────────────────────────────────────
export function KanbanBoardPage() {
const k = useKanban()
const qc = useQueryClient()
const slug = useValue($boardSlug)
const [archived, setArchived] = useState(false)
@ -1188,7 +1202,7 @@ export function KanbanBoardPage() {
}
if (isLockedTarget(status)) {
host.notify({ kind: 'info', message: LOCKED_COLUMNS[status] })
host.notify({ kind: 'info', message: lockedReason(k, status) })
return
}
@ -1267,7 +1281,7 @@ export function KanbanBoardPage() {
</Contribute>
<header className="flex shrink-0 flex-wrap items-center gap-2 px-4 py-2">
<h1 className="text-sm font-semibold text-foreground">Kanban</h1>
<h1 className="text-sm font-semibold text-foreground">{k.title}</h1>
<span className="rounded-full bg-(--ui-bg-quaternary) px-1.5 py-px text-[0.625rem] tabular-nums text-(--ui-text-tertiary)">
{total}
</span>
@ -1282,11 +1296,11 @@ export function KanbanBoardPage() {
tenant={tenant}
/>
)}
<SearchField aria-label="Filter cards" onChange={setSearch} placeholder="Filter cards…" value={search} />
<SearchField aria-label={k.filterCards} onChange={setSearch} placeholder={k.filterCards} value={search} />
<div className="ml-auto flex items-center gap-1">
<Tip label="Orchestration settings">
<Tip label={k.orchestrationSettings}>
<Button
aria-label="Orchestration settings"
aria-label={k.orchestrationSettings}
className={cn(settingsOpen && 'bg-(--ui-control-active-background) text-foreground')}
onClick={() => setSettingsOpen(!settingsOpen)}
size="icon-xs"
@ -1297,7 +1311,7 @@ export function KanbanBoardPage() {
</Tip>
<Button onClick={() => setAddStatus('triage')} size="sm">
<Codicon name="add" size="0.8rem" />
New task
{k.newTask}
</Button>
</div>
</header>
@ -1319,11 +1333,11 @@ export function KanbanBoardPage() {
<div className="flex flex-col items-center gap-2">
<Codicon className="text-(--ui-text-quaternary)" name="project" size="1.25rem" />
<p className="text-xs text-(--ui-text-tertiary)">
{search || tenant || assignee ? 'No tasks match the filters' : 'No tasks on this board'}
{search || tenant || assignee ? k.noMatch : k.noTasks}
</p>
<Button className="mt-0.5" onClick={() => setAddStatus('triage')} size="sm" variant="outline">
<Codicon name="add" size="0.75rem" />
New task
{k.newTask}
</Button>
</div>
</div>

View file

@ -46,8 +46,6 @@ import {
uploadAttachment
} from './api'
import {
columnMeta,
COMPLEXITY_LABEL,
type Diagnostic,
type DiagnosticAction,
type KanbanAttachment,
@ -60,15 +58,18 @@ import {
ago,
Avatar,
Callout,
columnLabel,
duration,
errText,
isLockedTarget,
LOCKED_COLUMNS,
type KanbanText,
lockedReason,
ScrollFade,
Section,
shortId,
StatusMenu,
useDefaultAssignee
useDefaultAssignee,
useKanban
} from './ui'
/**
@ -78,7 +79,7 @@ import {
* prose with the payload folded in; unknown kinds fall back to kind + compact
* key=value detail so new backend events still say something.
*/
function eventText(event: KanbanEvent): { detail?: string; label: string } {
function eventText(event: KanbanEvent, k: KanbanText): { detail?: string; label: string } {
let p: Record<string, unknown> = {}
if (typeof event.payload === 'string' && event.payload) {
@ -100,69 +101,62 @@ function eventText(event: KanbanEvent): { detail?: string; label: string } {
const col = (key: string) => {
const value = str(key)
return value ? columnMeta(value).label : null
return value ? columnLabel(k, value) : null
}
switch (event.kind) {
case 'created': {
const where = col('status')
const assignee = str('assignee')
return {
label: `created${where ? ` in ${where}` : ''}${assignee ? ` · assigned to ${assignee}` : ''}`
}
}
case 'created':
return { label: k.evtCreated(col('status') ?? '', str('assignee') ?? '') }
case 'status': {
const reason = str('reason')
return {
label: `moved to ${col('status') ?? '?'}`,
detail: reason === 'parent_reopened' ? `parent ${str('parent') ?? ''} reopened` : (reason ?? undefined)
label: k.evtMovedTo(col('status') ?? '?'),
detail: reason === 'parent_reopened' ? k.evtParentReopened(str('parent') ?? '') : (reason ?? undefined)
}
}
case 'assigned': {
const assignee = str('assignee')
return { label: assignee ? `assigned to ${assignee}` : 'unassigned' }
return { label: assignee ? k.evtAssignedTo(assignee) : k.evtUnassigned }
}
case 'commented':
return { label: `comment by ${str('author') ?? 'someone'}` }
return { label: k.evtCommentBy(str('author') ?? k.someone) }
case 'claimed':
return { label: str('source_status') === 'review' ? 'claimed by a review agent' : 'claimed by a worker' }
return { label: str('source_status') === 'review' ? k.evtClaimedReview : k.evtClaimedWorker }
case 'spawned':
return { label: 'worker started', detail: p.pid != null ? `pid ${p.pid}` : undefined }
return { label: k.evtWorkerStarted, detail: p.pid != null ? `pid ${p.pid}` : undefined }
case 'completed':
return { label: 'completed' }
return { label: k.evtCompleted }
case 'blocked':
return { label: 'blocked — needs human input', detail: str('reason') ?? undefined }
return { label: k.evtBlocked, detail: str('reason') ?? undefined }
case 'unblocked':
return { label: `unblocked${col('status') ? ` → ${col('status')}` : ' → Ready'}` }
return { label: k.evtUnblocked(col('status') ?? '') }
case 'reclaimed':
return { label: 'reclaimed — returned to the queue', detail: str('reason') ?? undefined }
return { label: k.evtReclaimed, detail: str('reason') ?? undefined }
case 'specified':
return { label: 'spec written by the triage agent' }
return { label: k.evtSpecified }
case 'promoted':
return { label: 'dependencies done — promoted to Ready' }
return { label: k.evtPromoted }
case 'scheduled':
return { label: 'scheduled for later', detail: str('reason') ?? undefined }
return { label: k.evtScheduled, detail: str('reason') ?? undefined }
case 'archived':
return { label: 'archived' }
return { label: k.evtArchived }
case 'reprioritized':
return { label: `priority set to ${p.priority ?? '?'}` }
return { label: k.evtReprioritized(String(p.priority ?? '?')) }
default: {
const detail = Object.entries(p)
.filter(([, value]) => value != null && typeof value !== 'object')
@ -187,12 +181,14 @@ function MetaRow({ children, label }: { children: ReactNode; label: string }) {
* 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: 'Command copied' })
host.notify({ kind: 'info', message: k.commandCopied })
}
}
@ -243,6 +239,7 @@ function AssigneeMenu({
current: null | string | undefined
onReassign: (p: string) => void
}) {
const k = useKanban()
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
return (
@ -258,7 +255,7 @@ function AssigneeMenu({
<span className="truncate">{current}</span>
</>
) : (
<span className="text-(--ui-text-quaternary)">unassigned</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>
@ -296,6 +293,7 @@ function CommentComposer({
pending: boolean
running?: boolean
}) {
const k = useKanban()
const [body, setBody] = useState('')
const submit = () => {
@ -328,7 +326,7 @@ function CommentComposer({
submit()
}
}}
placeholder={running ? 'Message the running worker…' : 'Add a comment…'}
placeholder={running ? k.messageWorker : k.addComment}
rows={1}
size="sm"
value={body}
@ -340,17 +338,15 @@ function CommentComposer({
size="xs"
variant="secondary"
>
{running ? 'Send' : 'Comment'}
{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)">
Delivered to the running worker within a few seconds.
</span>
<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" />
Requeue with note
{k.requeueWithNote}
</Button>
</div>
)}
@ -359,6 +355,7 @@ function CommentComposer({
}
function DescriptionSection({ body, onSave }: { body: null | string | undefined; onSave: (body: string) => void }) {
const k = useKanban()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
@ -366,7 +363,7 @@ function DescriptionSection({ body, onSave }: { body: null | string | undefined;
<Section
action={
<Button
aria-label={editing ? 'Cancel edit' : 'Edit description'}
aria-label={editing ? k.cancelEdit : k.editDescription}
onClick={() => {
setDraft(body ?? '')
setEditing(!editing)
@ -377,7 +374,7 @@ function DescriptionSection({ body, onSave }: { body: null | string | undefined;
<Codicon name={editing ? 'close' : 'edit'} size="0.75rem" />
</Button>
}
label="Description"
label={k.description}
>
{editing ? (
<div className="flex flex-col gap-1.5">
@ -395,13 +392,13 @@ function DescriptionSection({ body, onSave }: { body: null | string | undefined;
size="xs"
variant="secondary"
>
Save
{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)">No description yet.</p>
<p className="text-[0.8125rem] text-(--ui-text-quaternary)">{k.noDescription}</p>
)}
</Section>
)
@ -420,6 +417,7 @@ function AttachmentsSection({
onUpload: (file: File) => void
pending: boolean
}) {
const k = useKanban()
const fileRef = useRef<HTMLInputElement>(null)
return (
@ -441,7 +439,7 @@ function AttachmentsSection({
type="file"
/>
<Button
aria-label="Upload attachment"
aria-label={k.uploadAttachment}
disabled={pending}
onClick={() => fileRef.current?.click()}
size="icon-xs"
@ -451,7 +449,7 @@ function AttachmentsSection({
</Button>
</>
}
label={`Attachments · ${attachments.length}`}
label={k.attachments(attachments.length)}
>
{attachments.length > 0 ? (
<ul className="flex flex-col gap-1">
@ -463,7 +461,7 @@ function AttachmentsSection({
))}
</ul>
) : (
<p className="text-[0.75rem] text-(--ui-text-quaternary)">No attachments yet.</p>
<p className="text-[0.75rem] text-(--ui-text-quaternary)">{k.noAttachments}</p>
)}
</Section>
)
@ -475,6 +473,7 @@ function AttachmentsSection({
// 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({
@ -484,7 +483,7 @@ function EstimateSection({ id }: { id: string }) {
if (r.ok) {
setResult(r)
} else {
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
host.notify({ kind: 'warning', message: r.reason || k.couldNotEstimate })
}
}
})
@ -493,21 +492,19 @@ function EstimateSection({ id }: { id: string }) {
useEffect(() => setResult(null), [id])
return (
<Section label="Estimate">
<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)} tok
~{compactNumber(result.est_tokens)} {k.tokUnit}
</span>
{result.complexity && (
<span className="text-(--ui-text-tertiary)">
· {COMPLEXITY_LABEL[result.complexity] ?? result.complexity}
</span>
<span className="text-(--ui-text-tertiary)">· {k.complexity[result.complexity] ?? result.complexity}</span>
)}
<Tip label="Re-estimate">
<Tip label={k.reEstimate}>
<Button
aria-label="Re-estimate"
aria-label={k.reEstimate}
className="ml-auto"
disabled={est.isPending}
onClick={() => est.mutate()}
@ -526,10 +523,10 @@ function EstimateSection({ id }: { id: string }) {
<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 ? 'Estimating…' : 'Estimate effort'}
{est.isPending ? k.estimating : k.estimateEffort}
</Button>
<Tip label="Runs a quick auxiliary-model call to estimate tokens + complexity. A rough guide, not a bill.">
<span className="text-[0.625rem] text-(--ui-text-quaternary)">makes a model call</span>
<Tip label={k.estimateTipLong}>
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{k.makesModelCall}</span>
</Tip>
</div>
)}
@ -548,6 +545,7 @@ export function TaskDrawer({
onClose: () => void
onOpen: (id: string) => void
}) {
const k = useKanban()
const qc = useQueryClient()
const slug = useValue($boardSlug)
@ -636,7 +634,7 @@ export function TaskDrawer({
},
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => {
host.notify({ kind: 'info', message: 'Note posted — worker requeued' })
host.notify({ kind: 'info', message: k.notePosted })
invalidate()
}
})
@ -664,7 +662,7 @@ export function TaskDrawer({
}
if (isLockedTarget(status)) {
host.notify({ kind: 'info', message: LOCKED_COLUMNS[status] })
host.notify({ kind: 'info', message: lockedReason(k, status) })
return
}
@ -691,7 +689,7 @@ export function TaskDrawer({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label="Task actions"
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"
>
@ -702,35 +700,35 @@ export function TaskDrawer({
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.id)
host.notify({ kind: 'info', message: `Copied ${task.id}` })
host.notify({ kind: 'info', message: k.copiedId(task.id) })
}}
>
<Codicon name="copy" size="0.85rem" />
Copy task id
{k.copyTaskId}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.title || task.id)
host.notify({ kind: 'info', message: 'Copied title' })
host.notify({ kind: 'info', message: k.copiedTitle })
}}
>
<Codicon name="copy" size="0.85rem" />
Copy title
{k.copyTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
<Codicon name="archive" size="0.85rem" />
Archive task
{k.archiveTask}
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onSelect={mutate(() => deleteTask(task.id), onClose)}>
<Codicon name="trash" size="0.85rem" />
Delete task
{k.deleteTask}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
aria-label="Close"
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"
@ -756,36 +754,33 @@ export function TaskDrawer({
) : (
<div className="flex flex-col gap-4 text-sm">
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-[0.71rem]">
<MetaRow label="Assignee">
<MetaRow label={k.assignee}>
<AssigneeMenu
current={task.assignee}
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
/>
</MetaRow>
{typeof task.priority === 'number' && <MetaRow label="Priority">{task.priority}</MetaRow>}
{task.tenant && <MetaRow label="Tenant">{task.tenant}</MetaRow>}
{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="Workspace">
<MetaRow label={k.workspace}>
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
{task.workspace_path}
</MetaRow>
)}
{task.created_by && <MetaRow label="Created by">{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label="Created">{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label="Worker pid">{task.worker_pid}</MetaRow> : null}
{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="Ready, but unassigned — this card will never run." tone={SEVERITY_TONE.warning}>
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">
The dispatcher only claims Ready cards that have an assignee. Pick a profile in the Assignee field
above (or set a default assignee in the orchestration settings) and it runs within a minute.
</p>
<Callout 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={`Diagnostics · ${task.diagnostics.length}`}>
<Section label={k.diagnosticsN(task.diagnostics.length)}>
<Diagnostics items={task.diagnostics} onReclaim={() => void mutate(() => reclaimTask(task.id))()} />
</Section>
)}
@ -795,24 +790,24 @@ export function TaskDrawer({
<EstimateSection id={task.id} />
{task.result && (
<Section label="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="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="Dependencies">
<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' ? 'Blocked by' : 'Blocks'}
{side === 'parents' ? k.blockedBy : k.blocks}
</span>
{detail.links[side].map(linked => (
<button
@ -832,19 +827,13 @@ export function TaskDrawer({
<Section
action={
<Tip
label={
running
? '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.'
: 'Comments are added to the task thread. When a worker picks the task up it reads them as part of its context.'
}
>
<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={`Comments · ${detail.comments.length}`}
label={k.comments(detail.comments.length)}
>
{detail.comments.length > 0 && (
<ul className="flex flex-col gap-2">
@ -868,11 +857,11 @@ export function TaskDrawer({
</Section>
{detail.events.length > 0 && (
<Section label={`Activity · ${detail.events.length}`}>
<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)
const { detail: extra, label } = eventText(event, k)
return (
<li className="flex items-baseline gap-2 text-[0.6875rem]" key={event.id}>
@ -895,7 +884,7 @@ export function TaskDrawer({
)}
{detail.runs.length > 0 && (
<Section label={`Runs · ${detail.runs.length}`}>
<Section label={k.runs(detail.runs.length)}>
<ScrollFade max="11rem">
<ul className="flex flex-col gap-1.5">
{detail.runs.map(run => {
@ -936,7 +925,7 @@ export function TaskDrawer({
)}
{log?.exists && log.content && (
<Section label={`Worker log${log.truncated ? ' · tail' : ''}`}>
<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>

View file

@ -31,7 +31,7 @@ import {
saveProfileDescription
} from './api'
import type { KanbanProfile } from './types'
import { errText, FIELD_LABEL } from './ui'
import { errText, FIELD_LABEL, useKanban } from './ui'
const DEFAULT_SENTINEL = '__default__'
@ -46,6 +46,8 @@ function ProfilePicker({
profiles: KanbanProfile[]
value: string
}) {
const k = useKanban()
return (
<label className="flex min-w-0 flex-col gap-1">
<span className={FIELD_LABEL}>{label}</span>
@ -54,7 +56,7 @@ function ProfilePicker({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_SENTINEL}>(default)</SelectItem>
<SelectItem value={DEFAULT_SENTINEL}>{k.defaultParen}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
@ -67,6 +69,7 @@ function ProfilePicker({
}
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 })
@ -94,12 +97,12 @@ function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
<div className="flex items-center gap-2">
<span className="w-24 shrink-0 truncate text-[0.75rem] font-medium text-(--ui-text-secondary)">
{profile.name}
{profile.is_default && <span className="ml-1 text-[0.625rem] text-(--ui-text-quaternary)">(default)</span>}
{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="What is this profile good at?"
placeholder={k.profileGoodAt}
value={draft}
/>
<Button
@ -108,12 +111,12 @@ function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
size="xs"
variant="outline"
>
Save
{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' : ''}>Auto</span>
<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" />
@ -125,6 +128,7 @@ function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
}
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 })
@ -143,33 +147,31 @@ export function OrchestrationPanel() {
<div className="flex flex-col gap-4 border-t border-(--ui-stroke-tertiary) px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
<ProfilePicker
label="Orchestrator profile"
label={k.orchestratorProfile}
onSave={name => save.mutate({ orchestrator_profile: name })}
profiles={roster.profiles}
value={settings.orchestrator_profile}
/>
<ProfilePicker
label="Default assignee"
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="Auto-decompose triage tasks"
aria-label={k.autoDecompose}
checked={settings.auto_decompose}
onCheckedChange={checked => save.mutate({ auto_decompose: checked })}
size="xs"
/>
Auto-decompose triage tasks
{k.autoDecompose}
</label>
</div>
<div className="flex flex-col gap-1.5">
<span className={FIELD_LABEL}>Profile descriptions</span>
<p className="text-[0.6875rem] text-(--ui-text-quaternary)">
Descriptions guide the decomposer's routing. Auto-generate with the auxiliary model, or write your own.
</p>
<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} />
))}

View file

@ -30,11 +30,13 @@ import {
import { $boardSlug, bindApi, boardKey, fetchBoard } from './api'
import { KanbanBoardPage } from './board'
import { 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.
@ -56,7 +58,7 @@ function KanbanCount() {
}
return (
<Tip label={`Kanban — ${count('running')} running, ${count('ready')} ready`}>
<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',

View file

@ -153,10 +153,6 @@ export interface TaskEstimate {
rationale?: null | string
model?: null | string
}
/** Human-readable complexity band (the backend returns the compact letter). */
export const COMPLEXITY_LABEL: Record<string, string> = { L: 'Large', M: 'Medium', S: 'Small' }
export interface BoardsResponse {
boards: BoardMeta[]
current: string
@ -187,59 +183,24 @@ export interface KanbanProfile {
description_auto: boolean
}
/** Column presentation: label + codicon + tone + one-line help. Order follows
* the backend's BOARD_COLUMNS; help text mirrors the dashboard so the workflow
* is self-explanatory (the board is a dispatcher queue, not a manual board).
* Anything the backend adds still renders via the fallback. */
export const COLUMN_META: Record<string, { label: string; codicon: string; tone: string; help: string }> = {
triage: {
label: 'Triage',
codicon: 'inbox',
tone: 'var(--ui-text-tertiary)',
help: 'Raw ideas — a specifier fleshes out the spec.'
},
todo: {
label: 'Todo',
codicon: 'circle-outline',
tone: 'var(--ui-text-secondary)',
help: 'Waiting on dependencies, or unassigned.'
},
scheduled: { label: 'Scheduled', codicon: 'watch', tone: '#a78bfa', help: 'Waiting for a scheduled time to arrive.' },
ready: {
label: 'Ready',
codicon: 'play-circle',
tone: '#60a5fa',
help: 'Dependencies satisfied — assign a profile and the dispatcher runs it.'
},
running: {
label: 'Running',
codicon: 'sync',
tone: '#34d399',
help: 'Claimed by a worker — an agent is on it. Set by the dispatcher.'
},
blocked: { label: 'Blocked', codicon: 'error', tone: '#f87171', help: 'The worker asked for human input.' },
review: {
label: 'Review',
codicon: 'eye',
tone: '#fbbf24',
help: 'A review agent is checking the work. Set by the dispatcher.'
},
done: {
label: 'Done',
codicon: 'pass',
tone: 'var(--ui-text-tertiary)',
help: 'Completed; dependent children become ready.'
},
archived: {
label: 'Archived',
codicon: 'archive',
tone: 'var(--ui-text-quaternary)',
help: 'Hidden from the default board view.'
}
/** Column presentation — codicon + tone only. Labels + help live in i18n
* (`t.kanban.col`); see `columnLabel`/`columnHelp` in ui.tsx. 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] ?? { label: name, codicon: 'circle-outline', tone: 'var(--ui-text-secondary)', help: '' }
COLUMN_META[name] ?? { codicon: 'circle-outline', tone: 'var(--ui-text-secondary)' }
export const SEVERITY_TONE: Record<Diagnostic['severity'], string> = {
critical: 'var(--destructive, #f87171)',

View file

@ -11,6 +11,7 @@ import {
profileColor,
profileColorSoft,
relativeTime,
useI18n,
useQuery
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
@ -18,6 +19,18 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 're
import { fetchOrchestration, ORCHESTRATION_KEY } from './api'
import { columnMeta, type KanbanTask } from './types'
/** The kanban i18n slice — one hook every plugin component reads `t.kanban` from. */
export function useKanban() {
return useI18n().t.kanban
}
export type KanbanText = ReturnType<typeof useKanban>
// Column labels + help live in i18n (visuals stay in COLUMN_META); unknown
// backend statuses fall back to the raw id / no help.
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 ?? ''
/** Orchestration knobs (cached app-wide; the settings panel invalidates). */
export function useOrchestration() {
return useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration, staleTime: 60_000 }).data
@ -32,14 +45,14 @@ export function useDefaultAssignee(): string {
// System-owned drop targets — you can drag a card OUT of these, never INTO
// them, so lanes/menus must not offer them as targets. `running`/`review` are
// claimed by the dispatcher; `scheduled` needs a wake-up time only an agent or
// the CLI can attach (a bare status drag is refused with a 409).
export const LOCKED_COLUMNS: Record<string, string> = {
review: 'Review is entered by the dispatcher when a review agent takes the card.',
running: 'Running is set by the dispatcher when a worker claims the card.',
scheduled: 'Scheduled needs a wake-up time — agents set it; it can’t be dragged into.'
}
// the CLI can attach (a bare status drag is refused with a 409). The reason
// copy lives in i18n (`t.kanban.locked`).
export const LOCKED_COLUMNS = ['review', 'running', 'scheduled'] as const
export const isLockedTarget = (name: string): boolean => name in LOCKED_COLUMNS
export const isLockedTarget = (name: string): boolean => (LOCKED_COLUMNS as readonly string[]).includes(name)
/** Reason a locked column can't be dropped into (for the info toast). */
export const lockedReason = (k: KanbanText, name: string) => k.locked[name as keyof KanbanText['locked']] ?? ''
export const shortId = (id?: null | string) => (id ?? '').replace(/^t_/, '').slice(0, 6)
@ -127,13 +140,9 @@ export function arcState(task: KanbanTask, fallbackAssignee: string): ArcState |
return queued ? 'queued' : null
}
export const ARC_TITLES = {
running: 'An agent is working on this now.',
stale: 'Claimed, but no worker heartbeat for 2+ minutes — the dispatcher will reclaim it.'
} as const
/** Ticking "working · 34s" line for running cards (elapsed since claim). */
export function RunClock({ task }: { task: KanbanTask }) {
const k = useKanban()
const elapsed = useTicking(task.started_at)
if (!elapsed) {
@ -142,7 +151,7 @@ export function RunClock({ task }: { task: KanbanTask }) {
return (
<span className="shrink-0 font-medium" style={{ color: columnMeta('running').tone }}>
working · {elapsed}
{k.working} · {elapsed}
</span>
)
}
@ -189,6 +198,7 @@ export function StatusMenu({
onMove: (status: string) => void
status: string
}) {
const k = useKanban()
const meta = columnMeta(status)
return (
@ -200,7 +210,7 @@ export function StatusMenu({
type="button"
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: meta.tone }} />
{meta.label}
{columnLabel(k, status)}
<Codicon name="chevron-down" size="0.7rem" />
</button>
</DropdownMenuTrigger>
@ -210,7 +220,7 @@ export function StatusMenu({
.map(name => (
<DropdownMenuItem key={name} onSelect={() => onMove(name)}>
<span className="size-2 rounded-full" style={{ backgroundColor: columnMeta(name).tone }} />
{columnMeta(name).label}
{columnLabel(k, name)}
{name === status && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}