dsh-taskboard 0.5.5 → 0.6.1

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 +26 -2
  2. package/lib/client.js +2663 -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 +340 -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 +133 -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,340 @@
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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent } from 'react'
10
+ import { createPortal } from 'react-dom'
11
+ import type { BoardController } from '../controller.ts'
12
+ import type { PromptCompletionItem } from '../../shared/api.ts'
13
+ import { useT, type Translate } from '../i18n/runtime.ts'
14
+
15
+ /** Default built-in slash commands (descriptions resolve through t at render,
16
+ * so they follow the GUI language live; host-provided items override by name). */
17
+ export const defaultCommands = (t: Translate): PromptCompletionItem[] => [
18
+ { name: 'goal', kind: 'command', description: t('slash.cmd.goal.desc'), hint: t('slash.cmd.goal.hint') },
19
+ { name: 'schedule', kind: 'command', description: t('slash.cmd.schedule.desc'), hint: t('slash.cmd.schedule.hint') },
20
+ { name: 'plan', kind: 'command', description: t('slash.cmd.plan.desc') },
21
+ { name: 'browser', kind: 'command', description: t('slash.cmd.browser.desc') },
22
+ { name: 'grill-me', kind: 'command', description: t('slash.cmd.grill-me.desc') },
23
+ { name: 'teamwork-preview', kind: 'command', description: t('slash.cmd.teamwork-preview.desc') },
24
+ { name: 'learn', kind: 'command', description: t('slash.cmd.learn.desc') },
25
+ { name: 'review', kind: 'command', description: t('slash.cmd.review.desc') },
26
+ { name: 'security', kind: 'command', description: t('slash.cmd.security.desc') },
27
+ { name: 'permission', kind: 'command', description: t('slash.cmd.permission.desc'), hint: t('slash.cmd.permission.hint') },
28
+ ]
29
+
30
+ /** Default built-in skills (descriptions resolve through t at render). */
31
+ export const defaultSkills = (t: Translate): PromptCompletionItem[] => [
32
+ { name: 'frontend-ui-engineering', kind: 'skill', description: t('slash.skill.frontend-ui-engineering') },
33
+ { name: 'api-and-interface-design', kind: 'skill', description: t('slash.skill.api-and-interface-design') },
34
+ { name: 'test-driven-development', kind: 'skill', description: t('slash.skill.test-driven-development') },
35
+ { name: 'debugging-and-error-recovery', kind: 'skill', description: t('slash.skill.debugging-and-error-recovery') },
36
+ { name: 'performance-optimization', kind: 'skill', description: t('slash.skill.performance-optimization') },
37
+ { name: 'ci-cd-and-automation', kind: 'skill', description: t('slash.skill.ci-cd-and-automation') },
38
+ { name: 'code-review-and-quality', kind: 'skill', description: t('slash.skill.code-review-and-quality') },
39
+ { name: 'code-simplification', kind: 'skill', description: t('slash.skill.code-simplification') },
40
+ { name: 'context-engineering', kind: 'skill', description: t('slash.skill.context-engineering') },
41
+ { name: 'doubt-driven-development', kind: 'skill', description: t('slash.skill.doubt-driven-development') },
42
+ { name: 'git-workflow-and-versioning', kind: 'skill', description: t('slash.skill.git-workflow-and-versioning') },
43
+ { name: 'idea-refine', kind: 'skill', description: t('slash.skill.idea-refine') },
44
+ { name: 'incremental-implementation', kind: 'skill', description: t('slash.skill.incremental-implementation') },
45
+ { name: 'interview-me', kind: 'skill', description: t('slash.skill.interview-me') },
46
+ { name: 'memory-leak-debugging', kind: 'skill', description: t('slash.skill.memory-leak-debugging') },
47
+ { name: 'observability-and-instrumentation', kind: 'skill', description: t('slash.skill.observability-and-instrumentation') },
48
+ { name: 'planning-and-task-breakdown', kind: 'skill', description: t('slash.skill.planning-and-task-breakdown') },
49
+ { name: 'security-and-hardening', kind: 'skill', description: t('slash.skill.security-and-hardening') },
50
+ { name: 'shipping-and-launch', kind: 'skill', description: t('slash.skill.shipping-and-launch') },
51
+ { name: 'source-driven-development', kind: 'skill', description: t('slash.skill.source-driven-development') },
52
+ { name: 'spec-driven-development', kind: 'skill', description: t('slash.skill.spec-driven-development') },
53
+ { name: 'using-agent-skills', kind: 'skill', description: t('slash.skill.using-agent-skills') },
54
+ ]
55
+
56
+ /** Props for SlashPromptInput. */
57
+ export interface SlashPromptInputProps {
58
+ value: string
59
+ onChange: (value: string) => void
60
+ controller?: BoardController
61
+ placeholder?: string
62
+ rows?: number
63
+ maxLength?: number
64
+ disabled?: boolean
65
+ autoFocus?: boolean
66
+ className?: string
67
+ ariaLabel?: string
68
+ }
69
+
70
+ /**
71
+ * Rich prompt textarea with / autocomplete for slash commands & skills.
72
+ */
73
+ export function SlashPromptInput({
74
+ value,
75
+ onChange,
76
+ controller,
77
+ placeholder,
78
+ rows = 4,
79
+ maxLength = 8000,
80
+ disabled = false,
81
+ autoFocus = false,
82
+ className,
83
+ ariaLabel,
84
+ }: SlashPromptInputProps) {
85
+ const t = useT()
86
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
87
+ const popupRef = useRef<HTMLDivElement>(null)
88
+ const listRef = useRef<HTMLDivElement>(null)
89
+ // Inline fixed-position style for the portaled popup (set by positionPopup).
90
+ const [popupStyle, setPopupStyle] = useState<CSSProperties>({})
91
+
92
+ // Autocomplete state: only HOST-provided items are stateful; the built-in
93
+ // defaults are re-derived per render so their descriptions follow the
94
+ // active locale live (host items override defaults by name).
95
+ const [hostCompletions, setHostCompletions] = useState<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] } | undefined>(undefined)
96
+ const completions = useMemo<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] }>(() => {
97
+ const merge = (defaults: PromptCompletionItem[], host: PromptCompletionItem[] | undefined): PromptCompletionItem[] => {
98
+ const map = new Map<string, PromptCompletionItem>()
99
+ for (const d of defaults) map.set(d.name, d)
100
+ for (const h of host ?? []) map.set(h.name, h)
101
+ return Array.from(map.values())
102
+ }
103
+ return { commands: merge(defaultCommands(t), hostCompletions?.commands), skills: merge(defaultSkills(t), hostCompletions?.skills) }
104
+ }, [t, hostCompletions])
105
+ const [popupOpen, setPopupOpen] = useState(false)
106
+ const [slashQuery, setSlashQuery] = useState('')
107
+ const [slashStart, setSlashStart] = useState(-1)
108
+ const [selectedIndex, setSelectedIndex] = useState(0)
109
+
110
+ // Fetch host completions if controller provided
111
+ useEffect(() => {
112
+ if (controller === undefined) return
113
+ let alive = true
114
+ void controller.fetchPromptCompletions().then(res => {
115
+ if (!alive || res === undefined) return
116
+ setHostCompletions({
117
+ commands: res.commands.map(c => ({ ...c, kind: 'command' })),
118
+ skills: res.skills.map(s => ({ ...s, kind: 'skill' })),
119
+ })
120
+ })
121
+ return () => { alive = false }
122
+ }, [controller])
123
+
124
+ // Filter items based on query
125
+ const filteredItems = useMemo<PromptCompletionItem[]>(() => {
126
+ const q = slashQuery.toLowerCase().trim()
127
+ const all = [...completions.commands, ...completions.skills]
128
+ if (q.length === 0) return all
129
+ return all.filter(item => item.name.toLowerCase().includes(q) || (item.description !== undefined && item.description.toLowerCase().includes(q)))
130
+ }, [completions, slashQuery])
131
+
132
+ // Keep selected index in bounds
133
+ useEffect(() => {
134
+ if (selectedIndex >= filteredItems.length) {
135
+ setSelectedIndex(Math.max(0, filteredItems.length - 1))
136
+ }
137
+ }, [filteredItems.length, selectedIndex])
138
+
139
+ // Keep the keyboard-highlighted option visible inside the scrolling list:
140
+ // mouse hovering only ever targets rendered rows, but ArrowUp/ArrowDown can
141
+ // move the highlight past the clipped edge. Adjust the list's scrollTop
142
+ // directly from rect deltas — NOT scrollIntoView, which would also scroll
143
+ // ancestor containers (the modal body behind the portaled popup).
144
+ useLayoutEffect(() => {
145
+ if (!popupOpen) return
146
+ const list = listRef.current
147
+ const active = list?.children[selectedIndex]
148
+ if (list === null || !(active instanceof HTMLElement)) return
149
+ const listRect = list.getBoundingClientRect()
150
+ const itemRect = active.getBoundingClientRect()
151
+ if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top
152
+ else if (itemRect.bottom > listRect.bottom) list.scrollTop += itemRect.bottom - listRect.bottom
153
+ }, [popupOpen, selectedIndex, filteredItems])
154
+
155
+ // Detect slash typing on cursor movement or text change
156
+ const checkSlashTrigger = (): void => {
157
+ const el = textareaRef.current
158
+ if (el === null) return
159
+ const pos = el.selectionStart
160
+ const currentText = el.value.slice(0, pos)
161
+
162
+ // Check if cursor is right after a word starting with /
163
+ const lastSlash = currentText.lastIndexOf('/')
164
+ if (lastSlash >= 0) {
165
+ const charBefore = lastSlash > 0 ? (currentText[lastSlash - 1] ?? '\n') : '\n'
166
+ const isWordStart = /\s/.test(charBefore) || lastSlash === 0
167
+ const queryPart = currentText.slice(lastSlash + 1)
168
+ const noWhitespaceInQuery = !/\s/.test(queryPart)
169
+
170
+ if (isWordStart && noWhitespaceInQuery) {
171
+ setSlashStart(lastSlash)
172
+ setSlashQuery(queryPart)
173
+ setPopupOpen(true)
174
+ return
175
+ }
176
+ }
177
+ setPopupOpen(false)
178
+ }
179
+
180
+ // Insert picked completion item
181
+ const applyCompletion = (item: PromptCompletionItem): void => {
182
+ const el = textareaRef.current
183
+ if (el === null || slashStart < 0) return
184
+ const pos = el.selectionStart
185
+ const before = value.slice(0, slashStart)
186
+ const after = value.slice(pos)
187
+ const inserted = `/${item.name} `
188
+ const nextText = before + inserted + after
189
+ onChange(nextText)
190
+ setPopupOpen(false)
191
+
192
+ // Restore focus & cursor position
193
+ setTimeout(() => {
194
+ if (textareaRef.current !== null) {
195
+ const nextPos = slashStart + inserted.length
196
+ textareaRef.current.focus()
197
+ textareaRef.current.setSelectionRange(nextPos, nextPos)
198
+ }
199
+ }, 0)
200
+ }
201
+
202
+ // Keyboard navigation for slash popup
203
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
204
+ if (popupOpen && filteredItems.length > 0) {
205
+ if (e.key === 'ArrowDown') {
206
+ e.preventDefault()
207
+ setSelectedIndex(prev => (prev + 1) % filteredItems.length)
208
+ return
209
+ }
210
+ if (e.key === 'ArrowUp') {
211
+ e.preventDefault()
212
+ setSelectedIndex(prev => (prev - 1 + filteredItems.length) % filteredItems.length)
213
+ return
214
+ }
215
+ if (e.key === 'Enter' || e.key === 'Tab') {
216
+ const picked = filteredItems[selectedIndex]
217
+ if (picked !== undefined) {
218
+ e.preventDefault()
219
+ applyCompletion(picked)
220
+ return
221
+ }
222
+ }
223
+ if (e.key === 'Escape') {
224
+ e.preventDefault()
225
+ setPopupOpen(false)
226
+ return
227
+ }
228
+ }
229
+ }
230
+
231
+ // The popup is portaled to document.body and fixed-positioned from the
232
+ // textarea's viewport rect: an absolute popup inside the scrollable modal
233
+ // body was clipped at the container's top edge (0.6.0 field report).
234
+ // Opens above by preference, flips below when the top is tight, and clamps
235
+ // to the viewport (maxHeight shrinks; the list scrolls internally).
236
+ const positionPopup = useCallback((): void => {
237
+ const anchor = textareaRef.current
238
+ if (anchor === null) return
239
+ const rect = anchor.getBoundingClientRect()
240
+ const gap = 6
241
+ const margin = 8
242
+ const vh = window.innerHeight
243
+ const measured = popupRef.current?.offsetHeight ?? 0
244
+ const natural = measured > 0 ? measured : 240
245
+ const roomAbove = rect.top - gap - margin
246
+ const roomBelow = vh - margin - (rect.bottom + gap)
247
+ const openBelow = roomBelow > roomAbove
248
+ const height = Math.min(natural, Math.max(openBelow ? roomBelow : roomAbove, 120))
249
+ const top = openBelow ? rect.bottom + gap : rect.top - gap - height
250
+ // Bail out (return prev) when unchanged: the layout effect below runs on
251
+ // every open render, and a fresh object here would re-render forever.
252
+ setPopupStyle(prev => (prev.left === rect.left && prev.top === top && prev.width === rect.width && prev.maxHeight === height
253
+ ? prev
254
+ : { position: 'fixed', left: rect.left, top, width: rect.width, maxHeight: height, zIndex: 100 }))
255
+ }, [])
256
+
257
+ // Reposition on every open render: the popup height follows the filtered
258
+ // item count, so typing changes the geometry too.
259
+ useLayoutEffect(() => {
260
+ if (!popupOpen) return
261
+ positionPopup()
262
+ })
263
+
264
+ // Follow scrolling and viewport resizes while open (capture phase: the
265
+ // modal body scrolls, the window itself does not).
266
+ useEffect(() => {
267
+ if (!popupOpen) return
268
+ window.addEventListener('scroll', positionPopup, true)
269
+ window.addEventListener('resize', positionPopup)
270
+ return () => {
271
+ window.removeEventListener('scroll', positionPopup, true)
272
+ window.removeEventListener('resize', positionPopup)
273
+ }
274
+ }, [popupOpen, positionPopup])
275
+
276
+ return (
277
+ <div className={`dsh-atb-prompt-wrap ${className ?? ''}`}>
278
+ <div className="dsh-atb-prompt-inner">
279
+ <textarea
280
+ ref={textareaRef}
281
+ className="dsh-atb-prompt-input"
282
+ value={value}
283
+ rows={rows}
284
+ maxLength={maxLength}
285
+ disabled={disabled}
286
+ autoFocus={autoFocus}
287
+ placeholder={placeholder}
288
+ aria-label={ariaLabel}
289
+ onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
290
+ onChange(e.target.value)
291
+ checkSlashTrigger()
292
+ }}
293
+ onKeyUp={checkSlashTrigger}
294
+ onClick={checkSlashTrigger}
295
+ onKeyDown={handleKeyDown}
296
+ />
297
+
298
+ {/* Slash Autocomplete Popup — portaled to document.body so the
299
+ scrollable modal body can never clip it (see positionPopup). */}
300
+ {popupOpen && filteredItems.length > 0 && createPortal(
301
+ <div ref={popupRef} className="dsh-atb-slash-popup" style={popupStyle} role="listbox" aria-label={t('slash.aria')}>
302
+ <div className="dsh-atb-slash-head">
303
+ <span className="dsh-atb-slash-title">{t('slash.title')}</span>
304
+ <span className="dsh-atb-slash-hint">{t('slash.hint')}</span>
305
+ </div>
306
+ <div ref={listRef} className="dsh-atb-slash-list">
307
+ {filteredItems.map((item, idx) => (
308
+ <div
309
+ key={`${item.kind}-${item.name}`}
310
+ role="option"
311
+ aria-selected={idx === selectedIndex}
312
+ className="dsh-atb-slash-item"
313
+ data-active={idx === selectedIndex ? 'true' : undefined}
314
+ data-kind={item.kind}
315
+ onClick={() => applyCompletion(item)}
316
+ onMouseEnter={() => setSelectedIndex(idx)}
317
+ >
318
+ <span className="dsh-atb-slash-badge" data-kind={item.kind}>
319
+ {item.kind === 'command' ? t('slash.badge.command') : t('slash.badge.skill')}
320
+ </span>
321
+ <span className="dsh-atb-slash-name">/{item.name}</span>
322
+ {item.hint && <span className="dsh-atb-slash-param">{item.hint}</span>}
323
+ {item.description && <span className="dsh-atb-slash-desc">{item.description}</span>}
324
+ </div>
325
+ ))}
326
+ </div>
327
+ </div>,
328
+ document.body,
329
+ )}
330
+ </div>
331
+
332
+ {/* Bottom helper toolbar */}
333
+ <div className="dsh-atb-prompt-foot">
334
+ <span className="dsh-atb-prompt-tip">
335
+ {t('slash.tipA')} <code>/</code> {t('slash.tipB')}
336
+ </span>
337
+ </div>
338
+ </div>
339
+ )
340
+ }
@@ -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
  ))}