mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
901205420f
commit
f6c8f35a4a
12 changed files with 1174 additions and 282 deletions
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '自動'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '自動'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: '自动'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue