dsh-taskboard 0.6.6 → 0.7.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 (64) hide show
  1. package/README.md +305 -288
  2. package/lib/client.js +985 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/assets.js +139 -0
  6. package/lib/host/assets.js.map +1 -0
  7. package/lib/host/execution.js +3 -0
  8. package/lib/host/execution.js.map +1 -1
  9. package/lib/host/locale.js +17 -0
  10. package/lib/host/locale.js.map +1 -0
  11. package/lib/host/routes.js +132 -6
  12. package/lib/host/routes.js.map +1 -1
  13. package/lib/host/scheduler.js +2 -0
  14. package/lib/host/scheduler.js.map +1 -1
  15. package/lib/host/session-sync.js +4 -1
  16. package/lib/host/session-sync.js.map +1 -1
  17. package/lib/host/storage-queue.js +14 -0
  18. package/lib/host/storage-queue.js.map +1 -0
  19. package/lib/host/storage.js +249 -0
  20. package/lib/host/storage.js.map +1 -0
  21. package/lib/host/store.js +34 -7
  22. package/lib/host/store.js.map +1 -1
  23. package/lib/host/templates.js +73 -113
  24. package/lib/host/templates.js.map +1 -1
  25. package/lib/host/tools.js +23 -8
  26. package/lib/host/tools.js.map +1 -1
  27. package/lib/index.js +83 -27
  28. package/lib/index.js.map +1 -1
  29. package/lib/shared/api.js.map +1 -1
  30. package/lib/shared/builtin-templates.js +155 -0
  31. package/lib/shared/builtin-templates.js.map +1 -0
  32. package/lib/shared/protocol.js +39 -2
  33. package/lib/shared/protocol.js.map +1 -1
  34. package/package.json +90 -89
  35. package/src/client/api.ts +35 -1
  36. package/src/client/board/SettingsModal.tsx +67 -4
  37. package/src/client/board/SlashPromptInput.tsx +80 -1
  38. package/src/client/board/TaskBoard.tsx +16 -11
  39. package/src/client/board/TaskDetail.tsx +186 -17
  40. package/src/client/board/TaskFormModal.tsx +8 -5
  41. package/src/client/board/TemplateManager.tsx +22 -10
  42. package/src/client/controller.ts +67 -5
  43. package/src/client/i18n/en.ts +35 -3
  44. package/src/client/i18n/templates.ts +25 -0
  45. package/src/client/i18n/zh.ts +35 -3
  46. package/src/client/image-insert.ts +29 -0
  47. package/src/client/styles.ts +35 -2
  48. package/src/host/archive-sessions.ts +18 -0
  49. package/src/host/assets.ts +120 -0
  50. package/src/host/execution.ts +4 -1
  51. package/src/host/locale.ts +44 -0
  52. package/src/host/routes.ts +132 -7
  53. package/src/host/scheduler.ts +2 -0
  54. package/src/host/session-sync.ts +4 -1
  55. package/src/host/storage-queue.ts +10 -0
  56. package/src/host/storage.ts +212 -0
  57. package/src/host/store.ts +40 -12
  58. package/src/host/templates.ts +38 -66
  59. package/src/host/tools.ts +28 -11
  60. package/src/index.ts +88 -33
  61. package/src/shared/api.ts +32 -3
  62. package/src/shared/builtin-templates.ts +153 -0
  63. package/src/shared/protocol.ts +64 -0
  64. package/src/shared/version.ts +9 -9
@@ -212,6 +212,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
212
212
  // create/update/run round-trip is pending — a double click used to fire
213
213
  // duplicate creates (and runs) before the first one returned (review P0).
214
214
  const [busy, setBusy] = useState(false)
215
+ const [imageUploading, setImageUploading] = useState(false)
215
216
 
216
217
  // Focus the title and close on Esc while the dialog is open.
217
218
  useEffect(() => {
@@ -294,7 +295,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
294
295
  }
295
296
 
296
297
  const submit = (): void => {
297
- if (!valid || busy) return
298
+ if (!valid || busy || imageUploading) return
298
299
  const picked = buildPickedModel()
299
300
  if (!editing) saveLastModel(picked)
300
301
  const isolationOut = isolationPayload()
@@ -335,7 +336,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
335
336
 
336
337
  /** Save the form, then immediately trigger a manual run of the task. */
337
338
  const submitAndRun = (): void => {
338
- if (!valid || runBlocked || busy) return
339
+ if (!valid || runBlocked || busy || imageUploading) return
339
340
  const picked = buildPickedModel()
340
341
  if (!editing) saveLastModel(picked)
341
342
  const isolationOut = isolationPayload()
@@ -602,6 +603,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
602
603
  controller={controller}
603
604
  rows={7}
604
605
  placeholder={t('form.desc.placeholder')}
606
+ allowImages
607
+ onUploadingChange={setImageUploading}
605
608
  />
606
609
  </Field>
607
610
 
@@ -624,13 +627,13 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
624
627
  <button
625
628
  type="button"
626
629
  className="dsh-atb-btn"
627
- disabled={!valid || runBlocked || busy}
628
- title={runBlocked ? t('form.action.runBlockedTitle') : busy ? t('form.action.runBusyTitle') : t('form.action.runTitle')}
630
+ disabled={!valid || runBlocked || busy || imageUploading}
631
+ title={runBlocked ? t('form.action.runBlockedTitle') : busy || imageUploading ? t('form.action.runBusyTitle') : t('form.action.runTitle')}
629
632
  onClick={submitAndRun}
630
633
  >
631
634
  {t('form.action.run')}
632
635
  </button>
633
- <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid || busy} onClick={submit}>
636
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid || busy || imageUploading} onClick={submit}>
634
637
  {editing ? t('form.action.save') : t('form.action.create')}
635
638
  </button>
636
639
  </span>
@@ -7,8 +7,12 @@
7
7
  */
8
8
  import { useState } from 'react'
9
9
  import type { BoardController } from '../controller.ts'
10
+ import type { TaskTemplate } from '../../shared/api.ts'
11
+ import type { Urgency } from '../../shared/protocol.ts'
10
12
  import { useAlert } from './AlertModal.tsx'
13
+ import { URGENCY_KEYS } from './labels.ts'
11
14
  import { useT } from '../i18n/runtime.ts'
15
+ import { localizeBuiltinName, localizeBuiltinTask } from '../i18n/templates.ts'
12
16
 
13
17
  /**
14
18
  * The template manager modal.
@@ -23,12 +27,20 @@ export function TemplateManager({ controller }: { controller: BoardController })
23
27
 
24
28
  const close = (): void => controller.closeTemplateManager()
25
29
 
26
- const nameOf = (id: string, fallback: string): string => edits[id] ?? fallback
30
+ /** The name a row currently shows: an in-flight rename edit, else the localized name. */
31
+ const nameOf = (tpl: TaskTemplate): string => edits[tpl.id] ?? localizeBuiltinName(tpl)
32
+
33
+ /** Localized urgency label for the meta line ('' when the template pins none). */
34
+ const urgencyLabel = (urgency: string | undefined): string => {
35
+ if (urgency === undefined) return ''
36
+ const key = URGENCY_KEYS[urgency as Urgency]
37
+ return key !== undefined ? ` · ${t(key)}` : ` · ${urgency}`
38
+ }
27
39
 
28
40
  /** Save one template's rename. */
29
41
  const save = (id: string, name: string): void => {
30
42
  const template = state.templates.find(t => t.id === id)
31
- if (template === undefined || name === template.name) return
43
+ if (template === undefined || name === localizeBuiltinName(template)) return
32
44
  void controller.upsertTemplate({ id, name, task: template.task }).then(ok => {
33
45
  if (ok) {
34
46
  setEdits(prev => { const next = { ...prev }; delete next[id]; return next })
@@ -57,31 +69,31 @@ export function TemplateManager({ controller }: { controller: BoardController })
57
69
  <div key={tpl.id} className="dsh-atb-tplm-row">
58
70
  <input
59
71
  className="dsh-atb-tplm-name"
60
- value={nameOf(tpl.id, tpl.name)}
72
+ value={nameOf(tpl)}
61
73
  maxLength={60}
62
74
  spellCheck={false}
63
- aria-label={t('tpl.name.aria', { name: tpl.name })}
75
+ aria-label={t('tpl.name.aria', { name: localizeBuiltinName(tpl) })}
64
76
  onChange={e => setEdits(prev => ({ ...prev, [tpl.id]: e.target.value }))}
65
77
  onKeyDown={e => {
66
- if (e.key === 'Enter') save(tpl.id, nameOf(tpl.id, tpl.name))
78
+ if (e.key === 'Enter') save(tpl.id, nameOf(tpl))
67
79
  }}
68
80
  />
69
81
  <span
70
82
  className="dsh-atb-tplm-meta"
71
- title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${tpl.task.urgency !== undefined ? ` · ${tpl.task.urgency}` : ''}${tpl.task.permission !== undefined ? ` · ${t('shared.permission')}: ${tpl.task.permission}` : ''}`}
83
+ title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${urgencyLabel(tpl.task.urgency)}${tpl.task.permission !== undefined ? ` · ${t('shared.permission')}: ${tpl.task.permission}` : ''}`}
72
84
  >
73
85
  {tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}
74
86
  {tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}
75
- {tpl.task.urgency !== undefined ? ` · ${tpl.task.urgency}` : ''}
87
+ {urgencyLabel(tpl.task.urgency)}
76
88
  {tpl.task.permission !== undefined && tpl.task.permission !== 'workspace-write' ? ` · ${tpl.task.permission === 'read-only' ? t('tpl.meta.permReadOnly') : t('tpl.meta.permFull')}` : ''}
77
89
  </span>
78
90
  <span className="dsh-atb-tplm-btns">
79
91
  <button
80
92
  type="button"
81
93
  className="dsh-atb-btn"
82
- disabled={nameOf(tpl.id, tpl.name) === tpl.name || nameOf(tpl.id, tpl.name).trim().length === 0}
94
+ disabled={nameOf(tpl) === localizeBuiltinName(tpl) || nameOf(tpl).trim().length === 0}
83
95
  title={t('tpl.rename.title')}
84
- onClick={() => save(tpl.id, nameOf(tpl.id, tpl.name))}
96
+ onClick={() => save(tpl.id, nameOf(tpl))}
85
97
  >
86
98
  {t('tpl.rename.button')}
87
99
  </button>
@@ -91,7 +103,7 @@ export function TemplateManager({ controller }: { controller: BoardController })
91
103
  title={t('tpl.use.title')}
92
104
  onClick={() => {
93
105
  close()
94
- controller.newFromTemplate(tpl.task)
106
+ controller.newFromTemplate(localizeBuiltinTask(tpl))
95
107
  }}
96
108
  >
97
109
  {t('tpl.use.button')}
@@ -1,3 +1,4 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
1
2
  /**
2
3
  * The board controller: framework-free state holder the React views render
3
4
  * from. Owns the ledger snapshot, workspace listing, view state (open,
@@ -7,7 +8,7 @@
7
8
  *
8
9
  * @module dsh-taskboard/client/controller
9
10
  */
10
- import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
+ import type { AttachmentUpload, ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, StorageStatus, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
12
  import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
13
  import { emptyLedger } from '../shared/protocol.ts'
13
14
  import type { TaskboardClient } from './api.ts'
@@ -47,6 +48,8 @@ function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortB
47
48
 
48
49
  /** Controller snapshot the views render. */
49
50
  export interface ControllerState {
51
+ archiveSessionsSupported?: boolean
52
+ sessionArchive?: { taskId: string; result: SessionArchiveResult }
50
53
  boardOpen: boolean
51
54
  ledger: TaskLedger
52
55
  workspaces: WorkspaceView[]
@@ -75,6 +78,8 @@ export interface ControllerState {
75
78
  importOpen: boolean
76
79
  /** Board-settings modal visible (0.5.0). */
77
80
  settingsOpen: boolean
81
+ /** Current durable-data directory, loaded when settings opens. */
82
+ storage?: StorageStatus
78
83
  /** Fields a chosen template prefills into the create form (consumed on open). */
79
84
  templatePrefill?: TaskTemplateSpec
80
85
  /** Transient error surface (action failures); cleared on next success. */
@@ -185,7 +190,7 @@ export class BoardController {
185
190
  if (this.state.selectedId !== undefined) {
186
191
  selected = ledger.tasks.find(t => t.id === this.state.selectedId)
187
192
  }
188
- this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
193
+ this.setState({ archiveSessionsSupported: ledger.capabilities?.archiveSessions === true, ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
189
194
  if (this.seenRevision === undefined || ledger.revision >= this.seenRevision) break
190
195
  }
191
196
  } catch (error) {
@@ -422,10 +427,27 @@ export class BoardController {
422
427
  }
423
428
  }
424
429
 
430
+ /** Retry session archiving without repeating the task's terminal transition. */
431
+ async retryArchiveSessions(id: string): Promise<void> {
432
+ try {
433
+ if (this.client.archiveSessions === undefined) return
434
+ const result = await this.client.archiveSessions(id)
435
+ this.setState({ sessionArchive: { taskId: id, result } })
436
+ await this.refresh()
437
+ } catch (error) {
438
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
439
+ }
440
+ }
441
+
425
442
  /** Move a task (user surface: done allowed). */
426
- async move(id: string, ifVersion: number, status: string): Promise<void> {
443
+ async move(id: string, ifVersion: number, status: string, options?: { archiveSessions?: boolean }): Promise<void> {
427
444
  try {
428
- await this.client.move(id, { ifVersion, status })
445
+ const result = await this.client.move(id, {
446
+ ifVersion,
447
+ status,
448
+ ...(options?.archiveSessions !== undefined ? { archiveSessions: options.archiveSessions } : {}),
449
+ })
450
+ if (result.sessionArchive !== undefined) this.setState({ sessionArchive: { taskId: id, result: result.sessionArchive } })
429
451
  await this.refresh()
430
452
  } catch (error) {
431
453
  this.setState({ error: error instanceof Error ? error.message : String(error) })
@@ -504,6 +526,16 @@ export class BoardController {
504
526
  }
505
527
  }
506
528
 
529
+ /** Upload an image without putting its bytes in the ledger or agent context. */
530
+ async uploadImage(file: Blob): Promise<AttachmentUpload | undefined> {
531
+ try {
532
+ return await this.client.uploadImage(file)
533
+ } catch (error) {
534
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
535
+ return undefined
536
+ }
537
+ }
538
+
507
539
  /** Trigger a manual run (fresh in-project session, pinned model); `reuse` = 续跑. */
508
540
  async run(id: string, reuse = false): Promise<void> {
509
541
  try {
@@ -567,7 +599,12 @@ export class BoardController {
567
599
  closeDiagnostics(): void { this.setState({ diagOpen: false }) }
568
600
 
569
601
  /** Open the board-settings modal (0.5.0). */
570
- openSettings(): void { this.setState({ settingsOpen: true }) }
602
+ openSettings(): void {
603
+ this.setState({ settingsOpen: true })
604
+ void this.client.storage()
605
+ .then(storage => this.setState({ storage, error: undefined }))
606
+ .catch(error => this.setState({ error: error instanceof Error ? error.message : String(error) }))
607
+ }
571
608
 
572
609
  /** Close the board-settings modal. */
573
610
  closeSettings(): void { this.setState({ settingsOpen: false }) }
@@ -588,6 +625,31 @@ export class BoardController {
588
625
  }
589
626
  }
590
627
 
628
+ /** Validate a candidate host directory without changing the active store. */
629
+ async checkStorage(directory: string): Promise<boolean> {
630
+ try {
631
+ const storage = await this.client.checkStorage(directory)
632
+ this.setState({ storage, error: undefined })
633
+ return true
634
+ } catch (error) {
635
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
636
+ return false
637
+ }
638
+ }
639
+
640
+ /** Atomically migrate all three stores and refresh the displayed location. */
641
+ async migrateStorage(directory: string): Promise<boolean> {
642
+ try {
643
+ const storage = await this.client.migrateStorage(directory)
644
+ this.setState({ storage, error: storage.warnings.length === 0 ? undefined : storage.warnings.join('\n') })
645
+ await this.refresh()
646
+ return true
647
+ } catch (error) {
648
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
649
+ return false
650
+ }
651
+ }
652
+
591
653
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
592
654
  async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
593
655
  try {
@@ -213,6 +213,14 @@ export const en: TaskboardDict = {
213
213
  'detail.move.to': 'Move to → {status}',
214
214
  'detail.move.confirmDoneUnchecked': '{n} checklist items are still unchecked — confirm done?',
215
215
  'detail.move.confirmDone': 'Confirm done?',
216
+ 'detail.move.confirmArchive': 'Confirm archive this task?',
217
+ 'detail.move.confirmArchiveSessionWithId': 'Archive execution session {id} together?',
218
+ 'detail.move.confirmArchiveSessionCount': 'Archive {n} execution sessions together?',
219
+ 'detail.move.archiveWithSession': 'With session',
220
+ 'detail.move.archiveUnsupported': 'Session archiving is unavailable on this host',
221
+ 'detail.move.archiveResult': 'Card archived; {n} execution sessions archived. Failed sessions, if any:',
222
+ 'detail.move.archiveRetry': 'Archive / retry execution sessions',
223
+ 'detail.move.archiveCardOnly': 'Card only',
216
224
  'detail.move.confirm': 'Confirm',
217
225
  'detail.blocked.unmark': '✓ Unblock',
218
226
  'detail.blocked.mark': '⛔ Mark blocked',
@@ -331,14 +339,14 @@ export const en: TaskboardDict = {
331
339
  'tpl.use.button': 'Use',
332
340
  'tpl.delete.title': 'Delete this template',
333
341
  'tpl.renamed': 'Template renamed',
334
- 'tpl.foot.hint': 'Templates are stored with the ledger in the DSH home directory and survive upgrades',
342
+ 'tpl.foot.hint': 'Templates are stored with the ledger in the active data directory and survive upgrades',
335
343
 
336
344
  // ── import modal (ImportModal) ────────────────────────────────────
337
345
  'imp.aria': 'Import ledger',
338
346
  'imp.title': 'Import ledger',
339
347
  'imp.subtitle': 'Pick an exported JSON backup: preview first, then merge or replace everything',
340
348
  'imp.parseError': 'The file is not valid JSON',
341
- 'imp.note': 'The ⬇ JSON export is a same-format backup and can be imported to restore; the file\u2019s schemaVersion must match the current version.',
349
+ 'imp.note': 'The ⬇ JSON export restores the ledger. Back up the dsh-taskboard-assets folder in the data directory shown in Settings as well. The file\u2019s schemaVersion must match the current version.',
342
350
  'imp.previewing': 'Previewing…',
343
351
  'imp.stat.create': 'New',
344
352
  'imp.stat.overwrite': 'Overwrite (same id)',
@@ -362,7 +370,7 @@ export const en: TaskboardDict = {
362
370
  // ── board settings modal (SettingsModal) ──────────────────────────
363
371
  'set.aria': 'Board settings',
364
372
  'set.title': 'Board settings',
365
- 'set.subtitle': 'Global defaults for new tasks and session sync',
373
+ 'set.subtitle': 'New-task defaults, session sync, and local data storage',
366
374
  'set.iso.heading': 'Default execution isolation',
367
375
  'set.iso.noneHint': 'No git; works directly in the project directory (factory default)',
368
376
  'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others; multi-repo workspaces are mirrored whole (one branch per repo)',
@@ -379,6 +387,16 @@ export const en: TaskboardDict = {
379
387
  'set.foot.dirty': 'Unsaved changes',
380
388
  'set.foot.clean': 'Matches the current board settings',
381
389
  'set.action.save': 'Save settings',
390
+ 'set.storage.heading': 'Data storage location',
391
+ 'set.storage.hint': 'The task ledger, templates, and image attachments live together in this directory. Changing it copies and verifies everything before switching.',
392
+ 'set.storage.loading': 'Loading the current path…',
393
+ 'set.storage.current': 'Current path: {path}',
394
+ 'set.storage.assets': 'Image attachments: {count}, {size} MiB',
395
+ 'set.storage.default': 'Restore default path',
396
+ 'set.storage.check': 'Check path',
397
+ 'set.storage.migrate': 'Migrate data',
398
+ 'set.storage.migrating': 'Migrating…',
399
+ 'set.storage.confirm': 'Migrate the complete task ledger, templates, and image attachments?\n\nFrom: {from}\nTo: {to}\n\nWrites queue briefly during migration.',
382
400
 
383
401
  // ── execution permission (PR #14) ─────────────────────────────────
384
402
  'form.field.permission': 'Execution permission',
@@ -412,6 +430,10 @@ export const en: TaskboardDict = {
412
430
  'md.imageTitle': 'Click to view full size ({alt})',
413
431
  'md.lightboxAlt': 'Full-size preview',
414
432
  'md.closePreview': 'Close preview',
433
+ 'image.add': 'Insert image',
434
+ 'image.uploading': 'Uploading…',
435
+ 'image.hint': 'Choose, paste, or drop PNG/JPEG/GIF/WebP images up to 5 MB each',
436
+ 'image.defaultAlt': 'Image',
415
437
 
416
438
  // ── slash completion popup (PR #14) ───────────────────────────────
417
439
  'slash.aria': 'Quick commands and skills',
@@ -456,4 +478,14 @@ export const en: TaskboardDict = {
456
478
  'slash.skill.source-driven-development': 'Design from authoritative docs and source code',
457
479
  'slash.skill.spec-driven-development': 'Write clear technical specs before coding',
458
480
  'slash.skill.using-agent-skills': 'Discover and invoke agent skills dynamically',
481
+
482
+ // ── host-generated system comments (0.6.4; localized at render) ────
483
+ 'sys.execFailed': '[System] Execution failed: {error}; the task was returned to todo.',
484
+ 'sys.endedWithComment': '[System] The execution session ended with comments but was not moved to in review; the system moved it to in review automatically.',
485
+ 'sys.endedNoHandoff': '[System] The execution session ended without a protocol handoff (no comments, not moved to in review); the system moved it to in review automatically — review it, then send back or accept.',
486
+ 'sys.sessionError': '[System] Session execution error: {error}; the task was returned to todo.',
487
+ 'sys.sessionDone': '[System] The session finished; automatically moved to in review.',
488
+ 'sys.cronDead': '[System] The cron expression {cron} has no trigger time within 4 years; scheduling disabled — fix the cron and re-enable.',
489
+ 'sys.mergeSingle': '[System] Branch {branch} merged into the main worktree (--no-ff).',
490
+ 'sys.mergeMulti': '[System] Branches merged per repo (--no-ff): {summary}',
459
491
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Client-side localization of built-in task templates (0.6.4). Built-in
3
+ * templates are seeded host-side with zh fallback text; this module resolves
4
+ * the ACTIVE locale's content (name + task spec) so the new-task dropdown,
5
+ * the template manager, and the create-form prefill follow the GUI language
6
+ * live. Custom templates (no `builtin` flag) and unknown built-in ids pass
7
+ * through untouched.
8
+ *
9
+ * @module dsh-taskboard/client/i18n/templates
10
+ */
11
+ import type { TaskTemplate, TaskTemplateSpec } from '../../shared/api.ts'
12
+ import { builtinTemplateContent } from '../../shared/builtin-templates.ts'
13
+ import { localeStore } from './runtime.ts'
14
+
15
+ /** The localized display name for a template (stored name for custom ones). */
16
+ export function localizeBuiltinName(tpl: Pick<TaskTemplate, 'id' | 'name' | 'builtin'>): string {
17
+ if (tpl.builtin !== true) return tpl.name
18
+ return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.name ?? tpl.name
19
+ }
20
+
21
+ /** The localized task spec for a template (stored spec for custom ones). */
22
+ export function localizeBuiltinTask(tpl: Pick<TaskTemplate, 'id' | 'task' | 'builtin'>): TaskTemplateSpec {
23
+ if (tpl.builtin !== true) return tpl.task
24
+ return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.task ?? tpl.task
25
+ }
@@ -215,6 +215,14 @@ export const zh = {
215
215
  'detail.move.to': '移至→{status}',
216
216
  'detail.move.confirmDoneUnchecked': '仍有 {n} 项清单未勾选,确认完成?',
217
217
  'detail.move.confirmDone': '确认完成?',
218
+ 'detail.move.confirmArchive': '确认归档该任务?',
219
+ 'detail.move.confirmArchiveSessionWithId': '是否连同执行会话 {id} 一同归档?',
220
+ 'detail.move.confirmArchiveSessionCount': '是否连同 {n} 个执行会话一同归档?',
221
+ 'detail.move.archiveWithSession': '连同会话归档',
222
+ 'detail.move.archiveUnsupported': '当前宿主不支持会话归档',
223
+ 'detail.move.archiveResult': '卡片已归档;执行会话归档成功 {n} 个。失败会话如下(如有):',
224
+ 'detail.move.archiveRetry': '归档 / 重试归档执行会话',
225
+ 'detail.move.archiveCardOnly': '仅归档卡片',
218
226
  'detail.move.confirm': '确认',
219
227
  'detail.blocked.unmark': '✓ 解除受阻',
220
228
  'detail.blocked.mark': '⛔ 标记受阻',
@@ -333,14 +341,14 @@ export const zh = {
333
341
  'tpl.use.button': '用此新建',
334
342
  'tpl.delete.title': '删除该模板',
335
343
  'tpl.renamed': '模板已改名',
336
- 'tpl.foot.hint': '模板随台账一同保存在 DSH 主目录,升级不丢',
344
+ 'tpl.foot.hint': '模板随台账一同保存在当前数据目录,升级不丢',
337
345
 
338
346
  // ── import modal (ImportModal) ────────────────────────────────────
339
347
  'imp.aria': '导入台账',
340
348
  'imp.title': '导入台账',
341
349
  'imp.subtitle': '选择导出的 JSON 备份文件:先预览、再合并或整册替换',
342
350
  'imp.parseError': '文件不是合法 JSON',
343
- 'imp.note': '⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。',
351
+ 'imp.note': '⬇ JSON 导出可恢复台账;图片附件需同时备份设置页所示数据目录下的 dsh-taskboard-assets 文件夹。导入文件的 schemaVersion 必须与当前版本一致。',
344
352
  'imp.previewing': '预览中…',
345
353
  'imp.stat.create': '新增',
346
354
  'imp.stat.overwrite': '覆盖(同 id)',
@@ -364,7 +372,7 @@ export const zh = {
364
372
  // ── board settings modal (SettingsModal) ──────────────────────────
365
373
  'set.aria': '看板设置',
366
374
  'set.title': '看板设置',
367
- 'set.subtitle': '新建任务与会话同步的全局默认值',
375
+ 'set.subtitle': '新建任务、会话同步与本地数据存储',
368
376
  'set.iso.heading': '默认执行隔离',
369
377
  'set.iso.noneHint': '不使用 git,直接在项目目录工作(出厂默认)',
370
378
  'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染;多仓库工作区自动整区镜像(每仓库独立分支)',
@@ -381,6 +389,16 @@ export const zh = {
381
389
  'set.foot.dirty': '有未保存的修改',
382
390
  'set.foot.clean': '与看板当前设置一致',
383
391
  'set.action.save': '保存设置',
392
+ 'set.storage.heading': '数据存储位置',
393
+ 'set.storage.hint': '任务台账、模板和图片附件统一存放在此目录。修改路径会先完整复制并校验,再切换到新位置。',
394
+ 'set.storage.loading': '正在读取当前路径…',
395
+ 'set.storage.current': '当前路径:{path}',
396
+ 'set.storage.assets': '图片附件:{count} 个,{size} MiB',
397
+ 'set.storage.default': '恢复默认路径',
398
+ 'set.storage.check': '检查路径',
399
+ 'set.storage.migrate': '迁移数据',
400
+ 'set.storage.migrating': '迁移中…',
401
+ 'set.storage.confirm': '确认迁移全部任务台账、模板和图片附件?\n\n原路径:{from}\n新路径:{to}\n\n迁移期间写操作会短暂排队。',
384
402
 
385
403
  // ── execution permission (PR #14) ─────────────────────────────────
386
404
  'form.field.permission': '执行权限',
@@ -414,6 +432,10 @@ export const zh = {
414
432
  'md.imageTitle': '点击查看大图 ({alt})',
415
433
  'md.lightboxAlt': '大图预览',
416
434
  'md.closePreview': '关闭预览',
435
+ 'image.add': '插入图片',
436
+ 'image.uploading': '正在上传…',
437
+ 'image.hint': '支持选择、粘贴或拖入 PNG/JPEG/GIF/WebP,单张不超过 5 MB',
438
+ 'image.defaultAlt': '图片',
417
439
 
418
440
  // ── slash completion popup (PR #14) ───────────────────────────────
419
441
  'slash.aria': '快捷命令与技能',
@@ -458,6 +480,16 @@ export const zh = {
458
480
  'slash.skill.source-driven-development': '基于权威官方文档与源码进行设计实现',
459
481
  'slash.skill.spec-driven-development': '在编码前制定清晰的技术规范',
460
482
  'slash.skill.using-agent-skills': '发现并动态调用智能体各项专业技能',
483
+
484
+ // ── host-generated system comments (0.6.4; localized at render) ────
485
+ 'sys.execFailed': '[系统] 执行失败:{error};任务已退回待办。',
486
+ 'sys.endedWithComment': '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。',
487
+ 'sys.endedNoHandoff': '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。',
488
+ 'sys.sessionError': '[系统] 会话执行异常:{error};任务已退回待办。',
489
+ 'sys.sessionDone': '[系统] 会话执行完毕,已自动进入待验收。',
490
+ 'sys.cronDead': '[系统] 定时表达式 {cron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。',
491
+ 'sys.mergeSingle': '[系统] 分支 {branch} 已合并到主工作区(--no-ff)。',
492
+ 'sys.mergeMulti': '[系统] 分支已按仓库合并(--no-ff):{summary}',
461
493
  } as const
462
494
 
463
495
  /** The dictionary shape en.ts must match key-for-key. */
@@ -0,0 +1,29 @@
1
+ import type { AttachmentUpload } from '../shared/api.ts'
2
+
3
+ export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp'
4
+ const TYPES = new Set(IMAGE_ACCEPT.split(','))
5
+
6
+ /** Keep Markdown alt text single-line and unable to close its own bracket. */
7
+ export function imageAlt(fileName: string, fallback: string): string {
8
+ const withoutExtension = fileName.replace(/\.(?:png|jpe?g|gif|webp)$/i, '')
9
+ const clean = withoutExtension.replace(/[\[\]\r\n]/g, ' ').replace(/\s+/g, ' ').trim()
10
+ return clean.length > 0 ? clean.slice(0, 120) : fallback
11
+ }
12
+
13
+ export function imageMarkdown(asset: AttachmentUpload, alt: string): string {
14
+ return `![${alt}](${asset.url})`
15
+ }
16
+
17
+ /** Insert a block at the current selection, preserving readable line boundaries. */
18
+ export function insertImageMarkdown(value: string, start: number, end: number, markdown: string): { value: string; cursor: number } {
19
+ const before = value.slice(0, start)
20
+ const after = value.slice(end)
21
+ const prefix = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
22
+ const suffix = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
23
+ const inserted = `${prefix}${markdown}${suffix}`
24
+ return { value: before + inserted + after, cursor: before.length + inserted.length }
25
+ }
26
+
27
+ export function acceptedImageFiles(files: Iterable<File>): File[] {
28
+ return Array.from(files).filter(file => TYPES.has(file.type)).slice(0, 10)
29
+ }
@@ -365,7 +365,7 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
365
365
  .dsh-atb-movebtn[data-to="canceled"], .dsh-atb-movebtn[data-to="archived"] { opacity: .75; }
366
366
  .dsh-atb-movebtn[data-to="blocked"] { border-color: rgba(229,72,77,.45); }
367
367
  .dsh-atb-movebtn[data-to="blocked"]:hover { background: rgba(229,72,77,.1); }
368
- .dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; }
368
+ .dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
369
369
  .dsh-atb-confirm-label { font-size: 11.5px; color: var(--dsw-text-secondary, gray); }
370
370
 
371
371
  .dsh-atb-section { font-size: 13px; display: flex; flex-direction: column; gap: 7px; }
@@ -398,6 +398,27 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
398
398
  .dsh-atb-bubble-meta span { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
399
399
  .dsh-atb-bubble-body { font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
400
400
 
401
+ .dsh-atb-markdown-body { white-space: pre-wrap; word-break: break-word; }
402
+ .dsh-atb-detail-img-wrap {
403
+ display: inline-flex; flex-direction: column; gap: 4px; max-width: min(100%, 520px); margin: 6px 8px 6px 0;
404
+ vertical-align: top;
405
+ }
406
+ .dsh-atb-detail-img {
407
+ display: block; max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 8px; cursor: zoom-in;
408
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.22)); background: var(--dsw-bg-inset, rgba(128,128,128,.08));
409
+ }
410
+ .dsh-atb-detail-img-caption { font-size: 10.5px; color: var(--dsw-text-secondary, gray); overflow-wrap: anywhere; }
411
+ .dsh-atb-lightbox-backdrop {
412
+ position: fixed; inset: 0; z-index: 120; display: grid; place-items: center; padding: 28px;
413
+ background: rgba(0,0,0,.76); backdrop-filter: blur(3px);
414
+ }
415
+ .dsh-atb-lightbox-content { position: relative; max-width: 96vw; max-height: 92vh; }
416
+ .dsh-atb-lightbox-img { display: block; max-width: 96vw; max-height: 92vh; object-fit: contain; border-radius: 10px; }
417
+ .dsh-atb-lightbox-close {
418
+ position: absolute; top: -14px; right: -14px; width: 30px; height: 30px; border-radius: 999px; cursor: pointer;
419
+ border: 1px solid rgba(255,255,255,.38); background: rgba(20,20,20,.9); color: #fff;
420
+ }
421
+
401
422
  .dsh-atb-composer { display: flex; gap: 7px; align-items: flex-end; margin-top: 2px; }
402
423
  .dsh-atb-composer-input {
403
424
  flex: 1; font: inherit; font-size: 12.5px; line-height: 1.5; padding: 7px 10px; border-radius: 9px;
@@ -410,6 +431,14 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
410
431
  border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
411
432
  }
412
433
  .dsh-atb-composer-send:disabled { opacity: .4; cursor: default; }
434
+ .dsh-atb-image-actions { display: flex; align-items: center; gap: 8px; padding: 5px 1px 0; }
435
+ .dsh-atb-image-add {
436
+ flex: none; font: inherit; font-size: 11.5px; line-height: 1.4; padding: 4px 8px; border-radius: 7px; cursor: pointer;
437
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.3)); background: var(--dsw-bg-elevated, rgba(128,128,128,.08)); color: inherit;
438
+ }
439
+ .dsh-atb-image-add:hover:not(:disabled) { background: var(--dsw-bg-hover, rgba(128,128,128,.14)); }
440
+ .dsh-atb-image-add:disabled { opacity: .45; cursor: default; }
441
+ .dsh-atb-image-hint { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
413
442
 
414
443
  .dsh-atb-execlist { display: flex; flex-direction: column; gap: 5px; }
415
444
  .dsh-atb-exec-row {
@@ -854,9 +883,13 @@ color: var(--dsw-alias-state-business-primary, #3e63dd);
854
883
  .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
855
884
  .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
856
885
  /* ---------- 0.5.0 board settings ---------- */
857
- .dsh-atb-set { max-width: 460px; width: min(460px, 92vw); }
886
+ .dsh-atb-set { max-width: 620px; width: min(620px, 92vw); }
858
887
  .dsh-atb-set .dsh-atb-mode-picker { margin-top: 8px; }
859
888
  .dsh-atb-set .dsh-atb-isolation-note { margin-top: 10px; }
889
+ .dsh-atb-storage-path { width: 100%; margin-top: 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
890
+ .dsh-atb-storage-meta { display: grid; gap: 4px; margin-top: 8px; color: var(--dsh-atb-muted); font-size: 12px; overflow-wrap: anywhere; }
891
+ .dsh-atb-storage-error { color: var(--dsh-atb-danger); }
892
+ .dsh-atb-storage-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
860
893
 
861
894
  /* ---------- 0.5.5 SlashPromptInput & Permission Picker ---------- */
862
895
  .dsh-atb-perm-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
@@ -0,0 +1,18 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
2
+ import { taskAssociatedSessionIds, type TaskRecord } from '../shared/protocol.ts'
3
+
4
+ /** Idempotent best-effort archiving with explicit per-session outcomes. */
5
+ export async function archiveTaskSessions(task: TaskRecord, archive?: (id: string) => Promise<void>): Promise<SessionArchiveResult> {
6
+ const sessionIds = taskAssociatedSessionIds(task)
7
+ if (archive === undefined) return { archived: [], failed: [], unsupported: sessionIds }
8
+ const result: SessionArchiveResult = { archived: [], failed: [], unsupported: [] }
9
+ for (const sessionId of sessionIds) {
10
+ try {
11
+ await archive(sessionId)
12
+ result.archived.push(sessionId)
13
+ } catch (error) {
14
+ result.failed.push({ sessionId, error: error instanceof Error ? error.message : String(error) })
15
+ }
16
+ }
17
+ return result
18
+ }