dsh-taskboard 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +22 -2
  2. package/lib/client.js +2604 -767
  3. package/lib/host/execution.js +3 -0
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/routes.js +31 -1
  6. package/lib/host/routes.js.map +1 -1
  7. package/lib/host/session-sync.js +210 -10
  8. package/lib/host/session-sync.js.map +1 -1
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +100 -1
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +19 -1
  15. package/lib/shared/protocol.js.map +1 -1
  16. package/package.json +75 -75
  17. package/src/client/api.ts +8 -0
  18. package/src/client/board/AlertModal.tsx +3 -1
  19. package/src/client/board/ImportModal.tsx +26 -24
  20. package/src/client/board/SettingsModal.tsx +66 -26
  21. package/src/client/board/SlashPromptInput.tsx +272 -0
  22. package/src/client/board/TaskBoard.tsx +53 -49
  23. package/src/client/board/TaskCard.tsx +33 -21
  24. package/src/client/board/TaskDetail.tsx +169 -104
  25. package/src/client/board/TaskFormModal.tsx +254 -202
  26. package/src/client/board/TemplateManager.tsx +32 -29
  27. package/src/client/board/labels.ts +36 -27
  28. package/src/client/controller.ts +62 -2
  29. package/src/client/i18n/en.ts +455 -0
  30. package/src/client/i18n/runtime.ts +155 -0
  31. package/src/client/i18n/zh.ts +460 -0
  32. package/src/client/index.ts +182 -42
  33. package/src/client/sidebar-entry.ts +13 -3
  34. package/src/client/styles.ts +131 -0
  35. package/src/host/execution.ts +13 -0
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +334 -14
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +115 -1
  40. package/src/shared/api.ts +47 -0
  41. package/src/shared/protocol.ts +41 -0
  42. package/src/shared/version.ts +1 -1
@@ -0,0 +1,272 @@
1
+ /**
2
+ * SlashPromptInput: Rich text input component for task description & execution prompt.
3
+ * Features:
4
+ * - Slash autocomplete popup for commands and skills with keyboard navigation.
5
+ * - Clean text editing without image base64 pollution.
6
+ *
7
+ * @module dsh-taskboard/client/board/SlashPromptInput
8
+ */
9
+ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
10
+ import type { BoardController } from '../controller.ts'
11
+ import type { PromptCompletionItem } from '../../shared/api.ts'
12
+ import { useT, type Translate } from '../i18n/runtime.ts'
13
+
14
+ /** Default built-in slash commands (descriptions resolve through t at render,
15
+ * so they follow the GUI language live; host-provided items override by name). */
16
+ export const defaultCommands = (t: Translate): PromptCompletionItem[] => [
17
+ { name: 'goal', kind: 'command', description: t('slash.cmd.goal.desc'), hint: t('slash.cmd.goal.hint') },
18
+ { name: 'schedule', kind: 'command', description: t('slash.cmd.schedule.desc'), hint: t('slash.cmd.schedule.hint') },
19
+ { name: 'plan', kind: 'command', description: t('slash.cmd.plan.desc') },
20
+ { name: 'browser', kind: 'command', description: t('slash.cmd.browser.desc') },
21
+ { name: 'grill-me', kind: 'command', description: t('slash.cmd.grill-me.desc') },
22
+ { name: 'teamwork-preview', kind: 'command', description: t('slash.cmd.teamwork-preview.desc') },
23
+ { name: 'learn', kind: 'command', description: t('slash.cmd.learn.desc') },
24
+ { name: 'review', kind: 'command', description: t('slash.cmd.review.desc') },
25
+ { name: 'security', kind: 'command', description: t('slash.cmd.security.desc') },
26
+ { name: 'permission', kind: 'command', description: t('slash.cmd.permission.desc'), hint: t('slash.cmd.permission.hint') },
27
+ ]
28
+
29
+ /** Default built-in skills (descriptions resolve through t at render). */
30
+ export const defaultSkills = (t: Translate): PromptCompletionItem[] => [
31
+ { name: 'frontend-ui-engineering', kind: 'skill', description: t('slash.skill.frontend-ui-engineering') },
32
+ { name: 'api-and-interface-design', kind: 'skill', description: t('slash.skill.api-and-interface-design') },
33
+ { name: 'test-driven-development', kind: 'skill', description: t('slash.skill.test-driven-development') },
34
+ { name: 'debugging-and-error-recovery', kind: 'skill', description: t('slash.skill.debugging-and-error-recovery') },
35
+ { name: 'performance-optimization', kind: 'skill', description: t('slash.skill.performance-optimization') },
36
+ { name: 'ci-cd-and-automation', kind: 'skill', description: t('slash.skill.ci-cd-and-automation') },
37
+ { name: 'code-review-and-quality', kind: 'skill', description: t('slash.skill.code-review-and-quality') },
38
+ { name: 'code-simplification', kind: 'skill', description: t('slash.skill.code-simplification') },
39
+ { name: 'context-engineering', kind: 'skill', description: t('slash.skill.context-engineering') },
40
+ { name: 'doubt-driven-development', kind: 'skill', description: t('slash.skill.doubt-driven-development') },
41
+ { name: 'git-workflow-and-versioning', kind: 'skill', description: t('slash.skill.git-workflow-and-versioning') },
42
+ { name: 'idea-refine', kind: 'skill', description: t('slash.skill.idea-refine') },
43
+ { name: 'incremental-implementation', kind: 'skill', description: t('slash.skill.incremental-implementation') },
44
+ { name: 'interview-me', kind: 'skill', description: t('slash.skill.interview-me') },
45
+ { name: 'memory-leak-debugging', kind: 'skill', description: t('slash.skill.memory-leak-debugging') },
46
+ { name: 'observability-and-instrumentation', kind: 'skill', description: t('slash.skill.observability-and-instrumentation') },
47
+ { name: 'planning-and-task-breakdown', kind: 'skill', description: t('slash.skill.planning-and-task-breakdown') },
48
+ { name: 'security-and-hardening', kind: 'skill', description: t('slash.skill.security-and-hardening') },
49
+ { name: 'shipping-and-launch', kind: 'skill', description: t('slash.skill.shipping-and-launch') },
50
+ { name: 'source-driven-development', kind: 'skill', description: t('slash.skill.source-driven-development') },
51
+ { name: 'spec-driven-development', kind: 'skill', description: t('slash.skill.spec-driven-development') },
52
+ { name: 'using-agent-skills', kind: 'skill', description: t('slash.skill.using-agent-skills') },
53
+ ]
54
+
55
+ /** Props for SlashPromptInput. */
56
+ export interface SlashPromptInputProps {
57
+ value: string
58
+ onChange: (value: string) => void
59
+ controller?: BoardController
60
+ placeholder?: string
61
+ rows?: number
62
+ maxLength?: number
63
+ disabled?: boolean
64
+ autoFocus?: boolean
65
+ className?: string
66
+ ariaLabel?: string
67
+ }
68
+
69
+ /**
70
+ * Rich prompt textarea with / autocomplete for slash commands & skills.
71
+ */
72
+ export function SlashPromptInput({
73
+ value,
74
+ onChange,
75
+ controller,
76
+ placeholder,
77
+ rows = 4,
78
+ maxLength = 8000,
79
+ disabled = false,
80
+ autoFocus = false,
81
+ className,
82
+ ariaLabel,
83
+ }: SlashPromptInputProps) {
84
+ const t = useT()
85
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
86
+
87
+ // Autocomplete state: only HOST-provided items are stateful; the built-in
88
+ // defaults are re-derived per render so their descriptions follow the
89
+ // active locale live (host items override defaults by name).
90
+ const [hostCompletions, setHostCompletions] = useState<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] } | undefined>(undefined)
91
+ const completions = useMemo<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] }>(() => {
92
+ const merge = (defaults: PromptCompletionItem[], host: PromptCompletionItem[] | undefined): PromptCompletionItem[] => {
93
+ const map = new Map<string, PromptCompletionItem>()
94
+ for (const d of defaults) map.set(d.name, d)
95
+ for (const h of host ?? []) map.set(h.name, h)
96
+ return Array.from(map.values())
97
+ }
98
+ return { commands: merge(defaultCommands(t), hostCompletions?.commands), skills: merge(defaultSkills(t), hostCompletions?.skills) }
99
+ }, [t, hostCompletions])
100
+ const [popupOpen, setPopupOpen] = useState(false)
101
+ const [slashQuery, setSlashQuery] = useState('')
102
+ const [slashStart, setSlashStart] = useState(-1)
103
+ const [selectedIndex, setSelectedIndex] = useState(0)
104
+
105
+ // Fetch host completions if controller provided
106
+ useEffect(() => {
107
+ if (controller === undefined) return
108
+ let alive = true
109
+ void controller.fetchPromptCompletions().then(res => {
110
+ if (!alive || res === undefined) return
111
+ setHostCompletions({
112
+ commands: res.commands.map(c => ({ ...c, kind: 'command' })),
113
+ skills: res.skills.map(s => ({ ...s, kind: 'skill' })),
114
+ })
115
+ })
116
+ return () => { alive = false }
117
+ }, [controller])
118
+
119
+ // Filter items based on query
120
+ const filteredItems = useMemo<PromptCompletionItem[]>(() => {
121
+ const q = slashQuery.toLowerCase().trim()
122
+ const all = [...completions.commands, ...completions.skills]
123
+ if (q.length === 0) return all
124
+ return all.filter(item => item.name.toLowerCase().includes(q) || (item.description !== undefined && item.description.toLowerCase().includes(q)))
125
+ }, [completions, slashQuery])
126
+
127
+ // Keep selected index in bounds
128
+ useEffect(() => {
129
+ if (selectedIndex >= filteredItems.length) {
130
+ setSelectedIndex(Math.max(0, filteredItems.length - 1))
131
+ }
132
+ }, [filteredItems.length, selectedIndex])
133
+
134
+ // Detect slash typing on cursor movement or text change
135
+ const checkSlashTrigger = (): void => {
136
+ const el = textareaRef.current
137
+ if (el === null) return
138
+ const pos = el.selectionStart
139
+ const currentText = el.value.slice(0, pos)
140
+
141
+ // Check if cursor is right after a word starting with /
142
+ const lastSlash = currentText.lastIndexOf('/')
143
+ if (lastSlash >= 0) {
144
+ const charBefore = lastSlash > 0 ? (currentText[lastSlash - 1] ?? '\n') : '\n'
145
+ const isWordStart = /\s/.test(charBefore) || lastSlash === 0
146
+ const queryPart = currentText.slice(lastSlash + 1)
147
+ const noWhitespaceInQuery = !/\s/.test(queryPart)
148
+
149
+ if (isWordStart && noWhitespaceInQuery) {
150
+ setSlashStart(lastSlash)
151
+ setSlashQuery(queryPart)
152
+ setPopupOpen(true)
153
+ return
154
+ }
155
+ }
156
+ setPopupOpen(false)
157
+ }
158
+
159
+ // Insert picked completion item
160
+ const applyCompletion = (item: PromptCompletionItem): void => {
161
+ const el = textareaRef.current
162
+ if (el === null || slashStart < 0) return
163
+ const pos = el.selectionStart
164
+ const before = value.slice(0, slashStart)
165
+ const after = value.slice(pos)
166
+ const inserted = `/${item.name} `
167
+ const nextText = before + inserted + after
168
+ onChange(nextText)
169
+ setPopupOpen(false)
170
+
171
+ // Restore focus & cursor position
172
+ setTimeout(() => {
173
+ if (textareaRef.current !== null) {
174
+ const nextPos = slashStart + inserted.length
175
+ textareaRef.current.focus()
176
+ textareaRef.current.setSelectionRange(nextPos, nextPos)
177
+ }
178
+ }, 0)
179
+ }
180
+
181
+ // Keyboard navigation for slash popup
182
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
183
+ if (popupOpen && filteredItems.length > 0) {
184
+ if (e.key === 'ArrowDown') {
185
+ e.preventDefault()
186
+ setSelectedIndex(prev => (prev + 1) % filteredItems.length)
187
+ return
188
+ }
189
+ if (e.key === 'ArrowUp') {
190
+ e.preventDefault()
191
+ setSelectedIndex(prev => (prev - 1 + filteredItems.length) % filteredItems.length)
192
+ return
193
+ }
194
+ if (e.key === 'Enter' || e.key === 'Tab') {
195
+ const picked = filteredItems[selectedIndex]
196
+ if (picked !== undefined) {
197
+ e.preventDefault()
198
+ applyCompletion(picked)
199
+ return
200
+ }
201
+ }
202
+ if (e.key === 'Escape') {
203
+ e.preventDefault()
204
+ setPopupOpen(false)
205
+ return
206
+ }
207
+ }
208
+ }
209
+
210
+ return (
211
+ <div className={`dsh-atb-prompt-wrap ${className ?? ''}`}>
212
+ <div className="dsh-atb-prompt-inner">
213
+ <textarea
214
+ ref={textareaRef}
215
+ className="dsh-atb-prompt-input"
216
+ value={value}
217
+ rows={rows}
218
+ maxLength={maxLength}
219
+ disabled={disabled}
220
+ autoFocus={autoFocus}
221
+ placeholder={placeholder}
222
+ aria-label={ariaLabel}
223
+ onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
224
+ onChange(e.target.value)
225
+ checkSlashTrigger()
226
+ }}
227
+ onKeyUp={checkSlashTrigger}
228
+ onClick={checkSlashTrigger}
229
+ onKeyDown={handleKeyDown}
230
+ />
231
+
232
+ {/* Slash Autocomplete Popup */}
233
+ {popupOpen && filteredItems.length > 0 && (
234
+ <div className="dsh-atb-slash-popup" role="listbox" aria-label={t('slash.aria')}>
235
+ <div className="dsh-atb-slash-head">
236
+ <span className="dsh-atb-slash-title">{t('slash.title')}</span>
237
+ <span className="dsh-atb-slash-hint">{t('slash.hint')}</span>
238
+ </div>
239
+ <div className="dsh-atb-slash-list">
240
+ {filteredItems.map((item, idx) => (
241
+ <div
242
+ key={`${item.kind}-${item.name}`}
243
+ role="option"
244
+ aria-selected={idx === selectedIndex}
245
+ className="dsh-atb-slash-item"
246
+ data-active={idx === selectedIndex ? 'true' : undefined}
247
+ data-kind={item.kind}
248
+ onClick={() => applyCompletion(item)}
249
+ onMouseEnter={() => setSelectedIndex(idx)}
250
+ >
251
+ <span className="dsh-atb-slash-badge" data-kind={item.kind}>
252
+ {item.kind === 'command' ? t('slash.badge.command') : t('slash.badge.skill')}
253
+ </span>
254
+ <span className="dsh-atb-slash-name">/{item.name}</span>
255
+ {item.hint && <span className="dsh-atb-slash-param">{item.hint}</span>}
256
+ {item.description && <span className="dsh-atb-slash-desc">{item.description}</span>}
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ )}
262
+ </div>
263
+
264
+ {/* Bottom helper toolbar */}
265
+ <div className="dsh-atb-prompt-foot">
266
+ <span className="dsh-atb-prompt-tip">
267
+ {t('slash.tipA')} <code>/</code> {t('slash.tipB')}
268
+ </span>
269
+ </div>
270
+ </div>
271
+ )
272
+ }
@@ -9,7 +9,8 @@ import type { BoardController, ControllerState } from '../controller.ts'
9
9
  import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
10
10
  import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
11
11
  import { PLUGIN_VERSION } from '../../shared/version.ts'
12
- import { COLUMN_LABELS, URGENCY_LABEL } from './labels.ts'
12
+ import { COLUMN_KEYS, URGENCY_KEYS } from './labels.ts'
13
+ import { useT } from '../i18n/runtime.ts'
13
14
  import { fmtTime, isStaleClaim } from './format.ts'
14
15
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
15
16
  import { TaskDetail } from './TaskDetail.tsx'
@@ -43,6 +44,7 @@ export function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRe
43
44
  * @param controller - the controller.
44
45
  */
45
46
  export function TaskBoard({ controller }: { controller: BoardController }) {
47
+ const t = useT()
46
48
  const state = useSyncExternalStore(
47
49
  cb => controller.subscribe(cb),
48
50
  () => controller.getSnapshot(),
@@ -66,8 +68,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
66
68
  return (
67
69
  <div className="dsh-atb-board">
68
70
  <div className="dsh-atb-toolbar">
69
- <h2 className="dsh-atb-title">Agent 任务看板</h2>
70
- <span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
71
+ <h2 className="dsh-atb-title">{t('board.title')}</h2>
72
+ <span className="dsh-atb-count">{t('board.count.tasks', { n: live.length, rev: state.ledger.revision })}</span>
71
73
  <div className="dsh-atb-newmenu">
72
74
  <button
73
75
  type="button"
@@ -79,13 +81,13 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
79
81
  if (next) controller.prepareTemplateMenu()
80
82
  }}
81
83
  >
82
- + 新建任务 ▼
84
+ {t('board.action.newTask')}
83
85
  </button>
84
86
  {newMenuOpen && (
85
87
  <>
86
88
  <div className="dsh-atb-newmenu-backdrop" onClick={closeMenu} />
87
89
  <div className="dsh-atb-newmenu-list">
88
- <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>空白任务</button>
90
+ <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>{t('board.action.blankTask')}</button>
89
91
  {state.templates.map(t => (
90
92
  <button
91
93
  key={t.id}
@@ -98,7 +100,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
98
100
  </button>
99
101
  ))}
100
102
  <div className="dsh-atb-newmenu-sep" />
101
- <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>⌗ 管理模板…</button>
103
+ <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>{t('board.action.manageTemplates')}</button>
102
104
  </div>
103
105
  </>
104
106
  )}
@@ -107,7 +109,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
107
109
  <input
108
110
  className="dsh-atb-input dsh-atb-search"
109
111
  value={state.search}
110
- placeholder="搜索标题 / ID…"
112
+ placeholder={t('board.search.placeholder')}
111
113
  spellCheck={false}
112
114
  onChange={e => controller.setSearch(e.target.value)}
113
115
  />
@@ -116,20 +118,20 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
116
118
  value={state.filters.workspaceId ?? ''}
117
119
  onChange={e => controller.setWorkspaceFilter(e.target.value === '' ? undefined : e.target.value)}
118
120
  >
119
- <option value="">全部项目</option>
121
+ <option value="">{t('board.filter.allProjects')}</option>
120
122
  {state.workspaces.map(ws => <option key={ws.id} value={ws.id}>{ws.title || ws.path}</option>)}
121
123
  </select>
122
124
  <select
123
125
  className="dsh-atb-select"
124
126
  value={state.sortBy}
125
- title="列内排序"
127
+ title={t('board.sort.title')}
126
128
  onChange={e => controller.setSortBy(e.target.value as typeof state.sortBy)}
127
129
  >
128
- <option value="default">默认排序</option>
129
- <option value="updated">最近更新</option>
130
- <option value="urgency">按紧急度</option>
131
- <option value="created">创建时间</option>
132
- <option value="title">按标题</option>
130
+ <option value="default">{t('board.sort.default')}</option>
131
+ <option value="updated">{t('board.sort.updated')}</option>
132
+ <option value="urgency">{t('board.sort.urgency')}</option>
133
+ <option value="created">{t('board.sort.created')}</option>
134
+ <option value="title">{t('board.sort.byTitle')}</option>
133
135
  </select>
134
136
  {(['urgent', 'normal', 'relaxed'] as const).map(u => (
135
137
  <button
@@ -141,23 +143,23 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
141
143
  onClick={() => controller.toggleUrgency(u)}
142
144
  >
143
145
  <span className="dsh-atb-dot" data-urgency={u} />
144
- {URGENCY_LABEL[u]}
146
+ {t(URGENCY_KEYS[u])}
145
147
  </button>
146
148
  ))}
147
149
  <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
148
- {state.secondaryOpen ? '返回看板' : '其它任务'}
150
+ {state.secondaryOpen ? t('board.action.backToBoard') : t('board.action.otherTasks')}
149
151
  </button>
150
- <button type="button" className="dsh-atb-btn" title="看板设置:新建任务的默认执行隔离等" onClick={() => controller.openSettings()}>🛠 设置</button>
151
- <button type="button" className="dsh-atb-btn" title="健康诊断:遗留 worktree、台账基本项" onClick={() => controller.openDiagnostics()}>⚙ 诊断</button>
152
- <button type="button" className="dsh-atb-btn" title="从 JSON 备份文件导入台账(预览后合并或整册替换)" onClick={() => controller.openImport()}>⬆ 导入</button>
152
+ <button type="button" className="dsh-atb-btn" title={t('board.action.settingsTitle')} onClick={() => controller.openSettings()}>{t('board.action.settings')}</button>
153
+ <button type="button" className="dsh-atb-btn" title={t('board.action.diagTitle')} onClick={() => controller.openDiagnostics()}>{t('board.action.diag')}</button>
154
+ <button type="button" className="dsh-atb-btn" title={t('board.action.importTitle')} onClick={() => controller.openImport()}>{t('board.action.import')}</button>
153
155
  <div className="dsh-atb-newmenu">
154
156
  <button
155
157
  type="button"
156
158
  className="dsh-atb-btn"
157
- title="导出台账:完整 JSON 备份或任务清单 CSV"
159
+ title={t('board.action.exportTitle')}
158
160
  onClick={() => setExportOpen(!exportOpen)}
159
161
  >
160
- ⬇ 导出 ▼
162
+ {t('board.action.export')}
161
163
  </button>
162
164
  {exportOpen && (
163
165
  <>
@@ -166,18 +168,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
166
168
  <button
167
169
  type="button"
168
170
  className="dsh-atb-newmenu-opt"
169
- title="完整台账备份(含执行历史与看板设置),可用于导入恢复"
171
+ title={t('board.export.jsonTitle')}
170
172
  onClick={() => { closeExport(); controller.exportJson() }}
171
173
  >
172
- 完整台账(JSON)
174
+ {t('board.export.json')}
173
175
  </button>
174
176
  <button
175
177
  type="button"
176
178
  className="dsh-atb-newmenu-opt"
177
- title="任务清单表格(Excel 可直接打开,中文已加 BOM)"
179
+ title={t('board.export.csvTitle')}
178
180
  onClick={() => { closeExport(); controller.exportCsv() }}
179
181
  >
180
- 任务清单(CSV)
182
+ {t('board.export.csv')}
181
183
  </button>
182
184
  </div>
183
185
  </>
@@ -221,7 +223,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
221
223
  const task = state.ledger.tasks.find(t => t.id === id)
222
224
  if (task === undefined || task.status === status) return
223
225
  if (!canTransition(task.status, status)) {
224
- showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`)
226
+ showAlert(t('board.drag.forbidden', { from: t(COLUMN_KEYS[task.status]), to: t(COLUMN_KEYS[status]) }))
225
227
  return
226
228
  }
227
229
  void controller.move(id, task.version, status)
@@ -229,7 +231,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
229
231
  >
230
232
  <div className="dsh-atb-colhead">
231
233
  <span className="dsh-atb-dot" data-status={status} />
232
- {COLUMN_LABELS[status]}
234
+ {t(COLUMN_KEYS[status])}
233
235
  <span className="dsh-atb-colcount">{columnTasks.length}</span>
234
236
  </div>
235
237
  <div className="dsh-atb-cards">
@@ -243,7 +245,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
243
245
  onAlert={showAlert}
244
246
  />
245
247
  ))}
246
- {columnTasks.length === 0 && <div className="dsh-atb-empty">无任务</div>}
248
+ {columnTasks.length === 0 && <div className="dsh-atb-empty">{t('board.empty')}</div>}
247
249
  </div>
248
250
  </div>
249
251
  )
@@ -281,6 +283,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
281
283
 
282
284
  /** ⚙ Health-diagnostics panel (plan §3.6): ledger basics + orphan worktrees + one-click cleanup. */
283
285
  function DiagnosticsPanel({ controller }: { controller: BoardController }) {
286
+ const t = useT()
284
287
  const state = controller.getSnapshot()
285
288
  const diag = state.diagnostics
286
289
  const wsName = (id: string): string => {
@@ -289,52 +292,52 @@ function DiagnosticsPanel({ controller }: { controller: BoardController }) {
289
292
  }
290
293
  return (
291
294
  <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) controller.closeDiagnostics() }}>
292
- <div className="dsh-atb-modal dsh-atb-diag" role="dialog" aria-modal="true" aria-label="健康诊断">
295
+ <div className="dsh-atb-modal dsh-atb-diag" role="dialog" aria-modal="true" aria-label={t('diag.title')}>
293
296
  <div className="dsh-atb-modal-head">
294
297
  <span className="dsh-atb-modal-headicon">⚙</span>
295
298
  <div className="dsh-atb-modal-headtext">
296
- <h3>健康诊断</h3>
297
- <p>台账基本项与 worktree 遗留清理</p>
299
+ <h3>{t('diag.title')}</h3>
300
+ <p>{t('diag.subtitle')}</p>
298
301
  </div>
299
- <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={() => controller.closeDiagnostics()}>✕</button>
302
+ <button type="button" className="dsh-atb-modal-close" aria-label={t('shared.close')} onClick={() => controller.closeDiagnostics()}>✕</button>
300
303
  </div>
301
304
  <div className="dsh-atb-modal-body">
302
305
  {diag === undefined
303
- ? <div className="dsh-atb-empty2">读取中…</div>
306
+ ? <div className="dsh-atb-empty2">{t('shared.loading')}</div>
304
307
  : (
305
308
  <>
306
309
  <div className="dsh-atb-diag-grid">
307
- <div className="dsh-atb-diag-item"><b>{diag.revision}</b><span>台账修订号</span></div>
308
- <div className="dsh-atb-diag-item"><b>{diag.tasks}</b><span>任务总数</span></div>
309
- <div className="dsh-atb-diag-item" data-bad={diag.staleRunning > 0 ? 'true' : undefined}><b>{diag.staleRunning}</b><span>执行中</span></div>
310
- <div className="dsh-atb-diag-item" data-bad={diag.orphanWorktrees.length > 0 ? 'true' : undefined}><b>{diag.orphanWorktrees.length}</b><span>遗留 worktree</span></div>
310
+ <div className="dsh-atb-diag-item"><b>{diag.revision}</b><span>{t('diag.revision')}</span></div>
311
+ <div className="dsh-atb-diag-item"><b>{diag.tasks}</b><span>{t('diag.tasks')}</span></div>
312
+ <div className="dsh-atb-diag-item" data-bad={diag.staleRunning > 0 ? 'true' : undefined}><b>{diag.staleRunning}</b><span>{t('diag.running')}</span></div>
313
+ <div className="dsh-atb-diag-item" data-bad={diag.orphanWorktrees.length > 0 ? 'true' : undefined}><b>{diag.orphanWorktrees.length}</b><span>{t('diag.orphans')}</span></div>
311
314
  </div>
312
315
  <div className="dsh-atb-diag-sec">
313
- <h4>遗留 worktree(台账无主但目录存在)</h4>
316
+ <h4>{t('diag.orphans.heading')}</h4>
314
317
  {diag.orphanWorktrees.length === 0
315
- ? <div className="dsh-atb-empty2">无遗留 — 各项目 .dsh-worktrees 目录干净</div>
318
+ ? <div className="dsh-atb-empty2">{t('diag.orphans.none')}</div>
316
319
  : (
317
320
  <div className="dsh-atb-diag-orphans">
318
321
  {diag.orphanWorktrees.map(o => (
319
322
  <div key={o.path} className="dsh-atb-diag-orphan">
320
323
  <span className="dsh-atb-diag-orphan-path" title={o.path}>{wsName(o.workspaceId)} · {o.taskId}</span>
321
- <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.cleanupOrphan(o.workspaceId, o.taskId)}>清理</button>
324
+ <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.cleanupOrphan(o.workspaceId, o.taskId)}>{t('diag.orphans.cleanup')}</button>
322
325
  </div>
323
326
  ))}
324
327
  </div>
325
328
  )}
326
- <div className="dsh-atb-empty2">提示:有未提交修改的遗留目录会被拒绝清理,请先手动处理其内容。live 任务的 worktree 请在任务详情页删除。</div>
329
+ <div className="dsh-atb-empty2">{t('diag.orphans.hint')}</div>
327
330
  </div>
328
331
  <div className="dsh-atb-diag-sec">
329
- <h4>gitignore 建议</h4>
332
+ <h4>{t('diag.gitignore.heading')}</h4>
330
333
  {(diag.gitIgnoreSuggestions ?? []).length === 0
331
- ? <div className="dsh-atb-empty2">无待办 — 各 git 项目已忽略 .dsh-worktrees 目录</div>
334
+ ? <div className="dsh-atb-empty2">{t('diag.gitignore.none')}</div>
332
335
  : (
333
336
  <div className="dsh-atb-diag-orphans">
334
337
  {diag.gitIgnoreSuggestions.map(s => (
335
338
  <div key={s.workspaceId} className="dsh-atb-diag-orphan">
336
339
  <span className="dsh-atb-diag-orphan-path" title={s.workspacePath}>
337
- {wsName(s.workspaceId)} · 建议在 .gitignore 加入一行 <code>.dsh-worktrees/</code>(不会自动修改)
340
+ {wsName(s.workspaceId)} · {t('diag.gitignore.suggestA')} <code>.dsh-worktrees/</code>{t('diag.gitignore.suggestB')}
338
341
  </span>
339
342
  </div>
340
343
  ))}
@@ -351,20 +354,21 @@ function DiagnosticsPanel({ controller }: { controller: BoardController }) {
351
354
 
352
355
  /** Secondary tab: tasks grouped into canceled / archived / trashed columns. */
353
356
  function SecondaryTab({ controller, tasks }: { controller: BoardController; tasks: TaskRecord[] }) {
357
+ const t = useT()
354
358
  // Trashed takes precedence (a trashed task still carries its old status,
355
359
  // but what matters to the user is the pending purge).
356
360
  const trashed = tasks.filter(t => t.trashedAt !== undefined)
357
361
  const archived = tasks.filter(t => t.trashedAt === undefined && t.status === 'archived')
358
362
  const canceled = tasks.filter(t => t.trashedAt === undefined && t.status === 'canceled')
359
363
  const groups = [
360
- { label: '已取消', dot: 'canceled', rows: canceled },
361
- { label: '已归档', dot: 'archived', rows: archived },
362
- { label: '已删除', dot: 'trashed', rows: trashed },
364
+ { label: t('status.column.canceled'), dot: 'canceled', rows: canceled },
365
+ { label: t('status.column.archived'), dot: 'archived', rows: archived },
366
+ { label: t('board.group.trashed'), dot: 'trashed', rows: trashed },
363
367
  ]
364
368
  if (trashed.length + archived.length + canceled.length === 0) {
365
369
  return (
366
370
  <div className="dsh-atb-secondary">
367
- <div className="dsh-atb-empty">无已取消 / 已归档 / 已删除任务</div>
371
+ <div className="dsh-atb-empty">{t('board.secondary.empty')}</div>
368
372
  </div>
369
373
  )
370
374
  }
@@ -381,7 +385,7 @@ function SecondaryTab({ controller, tasks }: { controller: BoardController; task
381
385
  {group.rows.map(task => (
382
386
  <TaskCard key={task.id} task={task} controller={controller} />
383
387
  ))}
384
- {group.rows.length === 0 && <div className="dsh-atb-empty">无任务</div>}
388
+ {group.rows.length === 0 && <div className="dsh-atb-empty">{t('board.empty')}</div>}
385
389
  </div>
386
390
  </div>
387
391
  ))}