dsh-taskboard 0.6.5 → 0.6.7

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 (47) hide show
  1. package/README.md +301 -282
  2. package/lib/client.js +937 -920
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/execution.js +3 -0
  6. package/lib/host/execution.js.map +1 -1
  7. package/lib/host/locale.js +17 -0
  8. package/lib/host/locale.js.map +1 -0
  9. package/lib/host/routes.js +45 -6
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/scheduler.js +2 -0
  12. package/lib/host/scheduler.js.map +1 -1
  13. package/lib/host/session-sync.js +4 -1
  14. package/lib/host/session-sync.js.map +1 -1
  15. package/lib/host/templates.js +11 -75
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +19 -6
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/builtin-templates.js +155 -0
  21. package/lib/shared/builtin-templates.js.map +1 -0
  22. package/lib/shared/protocol.js +39 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +90 -90
  25. package/src/client/api.ts +5 -1
  26. package/src/client/board/TaskBoard.tsx +16 -11
  27. package/src/client/board/TaskDetail.tsx +116 -10
  28. package/src/client/board/TemplateManager.tsx +22 -10
  29. package/src/client/board-mount.tsx +4 -0
  30. package/src/client/controller.ts +23 -3
  31. package/src/client/i18n/en.ts +18 -0
  32. package/src/client/i18n/templates.ts +25 -0
  33. package/src/client/i18n/zh.ts +18 -0
  34. package/src/client/styles.ts +14 -3
  35. package/src/client/window-inset.ts +38 -0
  36. package/src/host/archive-sessions.ts +18 -0
  37. package/src/host/execution.ts +4 -1
  38. package/src/host/locale.ts +44 -0
  39. package/src/host/routes.ts +40 -7
  40. package/src/host/scheduler.ts +2 -0
  41. package/src/host/session-sync.ts +4 -1
  42. package/src/host/templates.ts +8 -59
  43. package/src/host/tools.ts +26 -9
  44. package/src/shared/api.ts +6 -3
  45. package/src/shared/builtin-templates.ts +153 -0
  46. package/src/shared/protocol.ts +64 -0
  47. package/src/shared/version.ts +9 -9
@@ -9,12 +9,12 @@
9
9
  */
10
10
  import { useEffect, useState, type ReactNode } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
- import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
- import { canTransition, checklistProgress } from '../../shared/protocol.ts'
12
+ import type { CommentRecord, ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
+ import { canTransition, checklistProgress, taskAssociatedSessionIds } from '../../shared/protocol.ts'
14
14
  import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './format.ts'
16
16
  import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
17
- import { useT } from '../i18n/runtime.ts'
17
+ import { useT, type Translate } from '../i18n/runtime.ts'
18
18
 
19
19
  /** Statuses a user may move this task to, per the state machine. */
20
20
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -28,6 +28,26 @@ function shortId(id: string | undefined): string {
28
28
  return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
29
29
  }
30
30
 
31
+ /**
32
+ * Render a comment body, localizing host-generated system messages (0.6.4).
33
+ * System comments carry a `systemKey` (+ flat params, or structured per-repo
34
+ * rows for the multi-repo merge summary); user/agent comments render raw.
35
+ */
36
+ export function commentBody(t: Translate, c: CommentRecord): string {
37
+ if (c.systemKey === undefined) return c.body
38
+ if (c.systemRows !== undefined) {
39
+ const summary = c.systemRows
40
+ .map(r => {
41
+ const label = r.repo === '' ? t('iso.repo.root') : r.repo
42
+ const mark = r.outcome === 'merged' ? '✓' : r.outcome === 'noop' ? '⟲' : '✗'
43
+ return r.outcome === 'failed' && r.error !== undefined ? `${label} ${mark} ${r.error}` : `${label} ${mark}`
44
+ })
45
+ .join(' · ')
46
+ return t(c.systemKey, { summary })
47
+ }
48
+ return t(c.systemKey, c.systemParams)
49
+ }
50
+
31
51
  /** Execution duration between start and end. */
32
52
  function duration(startedAt: number | undefined, endedAt: number | undefined): string {
33
53
  if (startedAt === undefined || endedAt === undefined) return ''
@@ -497,6 +517,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
497
517
  const [confirmDone, setConfirmDone] = useState(false)
498
518
  const [confirmPurge, setConfirmPurge] = useState(false)
499
519
  const [confirmCancel, setConfirmCancel] = useState(false)
520
+ const [confirmArchive, setConfirmArchive] = useState(false)
500
521
  // Top action buttons (duplicate / save-as-template / run / reuse-run)
501
522
  // share one in-flight guard: a double click used to fire duplicate runs or
502
523
  // copies while the first round-trip was still pending (review P0).
@@ -510,6 +531,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
510
531
  const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
511
532
  const sessionExecution = [...task.executions].reverse().find(e => e.sessionId !== undefined)
512
533
  const targetSessionId = runningExecution?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
534
+ const associatedSessions = taskAssociatedSessionIds(task)
535
+ const archiveState = controller.getSnapshot()
536
+ const archiveResult = archiveState.sessionArchive?.taskId === task.id ? archiveState.sessionArchive.result : undefined
513
537
 
514
538
  /** Fire one top action under the shared busy guard; re-enable on settle. */
515
539
  const runAction = (action: () => Promise<unknown>): void => {
@@ -680,10 +704,21 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
680
704
 
681
705
  <ChecklistBlock task={task} controller={controller} />
682
706
 
707
+ {archiveResult !== undefined && (
708
+ <div role="status" className="dsh-atb-confirm">
709
+ <span>{t('detail.move.archiveResult', { n: archiveResult.archived.length })}</span>
710
+ {archiveResult.failed.map(item => <span key={item.sessionId}>{item.sessionId}: {item.error}</span>)}
711
+ {archiveResult.unsupported.length > 0 && <span>{t('detail.move.archiveUnsupported')} {archiveResult.unsupported.join(', ')}</span>}
712
+ </div>
713
+ )}
714
+ {task.status === 'archived' && associatedSessions.length > 0 && archiveState.archiveSessionsSupported && (
715
+ <button type="button" className="dsh-atb-btn" disabled={actionBusy} onClick={() => runAction(() => controller.retryArchiveSessions(task.id))}>{t('detail.move.archiveRetry')}</button>
716
+ )}
683
717
  <div className="dsh-atb-detail-actions">
684
718
  <div className="dsh-atb-movebtns">
685
- {moveTargets(task).map(to => to === 'done'
686
- ? (confirmDone
719
+ {moveTargets(task).map(to => {
720
+ if (to === 'done') {
721
+ return confirmDone
687
722
  ? (
688
723
  <span key={to} className="dsh-atb-confirm">
689
724
  <span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
@@ -693,12 +728,83 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
693
728
  <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>{t('shared.cancel')}</button>
694
729
  </span>
695
730
  )
696
- : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>)
697
- : (
698
- <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
731
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => { setConfirmDone(true); setConfirmArchive(false) }}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>
732
+ }
733
+ if (to === 'archived') {
734
+ if (confirmArchive) {
735
+ return (
736
+ <span key={to} className="dsh-atb-confirm">
737
+ <span className="dsh-atb-confirm-label">
738
+ {associatedSessions.length === 1
739
+ ? t('detail.move.confirmArchiveSessionWithId', { id: shortId(associatedSessions[0]) })
740
+ : associatedSessions.length > 1
741
+ ? t('detail.move.confirmArchiveSessionCount', { n: associatedSessions.length })
742
+ : t('detail.move.confirmArchive')}
743
+ </span>
744
+ {associatedSessions.length > 0 && <span className="dsh-atb-confirm-label">{associatedSessions.join(', ')}</span>}
745
+ {associatedSessions.length > 0 ? (
746
+ <>
747
+ <button
748
+ type="button"
749
+ className="dsh-atb-btn"
750
+ disabled={!archiveState.archiveSessionsSupported || actionBusy}
751
+ title={!archiveState.archiveSessionsSupported ? t('detail.move.archiveUnsupported') : undefined}
752
+ onClick={() => {
753
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: true }))
754
+ setConfirmArchive(false)
755
+ }}
756
+ >
757
+ {t('detail.move.archiveWithSession')}
758
+ </button>
759
+ <button
760
+ type="button"
761
+ className="dsh-atb-btn"
762
+ onClick={() => {
763
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: false }))
764
+ setConfirmArchive(false)
765
+ }}
766
+ >
767
+ {t('detail.move.archiveCardOnly')}
768
+ </button>
769
+ </>
770
+ ) : (
771
+ <button
772
+ type="button"
773
+ className="dsh-atb-btn"
774
+ data-primary="true"
775
+ onClick={() => {
776
+ void controller.move(task.id, task.version, 'archived')
777
+ setConfirmArchive(false)
778
+ }}
779
+ >
780
+ {t('detail.move.confirm')}
781
+ </button>
782
+ )}
783
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmArchive(false)}>{t('shared.cancel')}</button>
784
+ </span>
785
+ )
786
+ }
787
+ return (
788
+ <button
789
+ key={to}
790
+ type="button"
791
+ className="dsh-atb-movebtn"
792
+ data-to={to}
793
+ onClick={() => {
794
+ setConfirmArchive(true)
795
+ setConfirmDone(false)
796
+ }}
797
+ >
699
798
  {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
700
799
  </button>
701
- ))}
800
+ )
801
+ }
802
+ return (
803
+ <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
804
+ {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
805
+ </button>
806
+ )
807
+ })}
702
808
  <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
703
809
  {task.blocked ? t('detail.blocked.unmark') : t('detail.blocked.mark')}
704
810
  </button>
@@ -730,7 +836,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
730
836
  <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
731
837
  <span>{fmtTime(c.createdAt)}</span>
732
838
  </div>
733
- <div className="dsh-atb-bubble-body">{c.body}</div>
839
+ <div className="dsh-atb-bubble-body">{commentBody(t, c)}</div>
734
840
  </div>
735
841
  </div>
736
842
  ))}
@@ -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')}
@@ -19,6 +19,7 @@ import { createRoot, type Root } from 'react-dom/client'
19
19
  import type { BoardController } from './controller.ts'
20
20
  import { TaskBoard } from './board/TaskBoard.tsx'
21
21
  import { ENTRY_SELECTOR } from './sidebar-entry.ts'
22
+ import { installWindowInset } from './window-inset.ts'
22
23
 
23
24
  /** The injected board container. */
24
25
  export const BOARD_VIEW_SELECTOR = '[data-dsh-atb-view]'
@@ -45,6 +46,7 @@ function conversationColumn(): HTMLElement | undefined {
45
46
  export function mountBoard(controller: BoardController): () => void {
46
47
  let root: Root | undefined
47
48
  let container: HTMLDivElement | undefined
49
+ let disposeWindowInset: (() => void) | undefined
48
50
 
49
51
  const ensure = (): void => {
50
52
  if (container !== undefined) return
@@ -54,6 +56,7 @@ export function mountBoard(controller: BoardController): () => void {
54
56
  container.dataset.dshAtbView = ''
55
57
  container.className = 'dsh-atb-view'
56
58
  column.appendChild(container)
59
+ disposeWindowInset = installWindowInset(container)
57
60
  root = createRoot(container)
58
61
  root.render(<TaskBoard controller={controller} />)
59
62
  }
@@ -101,6 +104,7 @@ export function mountBoard(controller: BoardController): () => void {
101
104
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
102
105
  waitObserver.disconnect()
103
106
  unsubscribe()
107
+ disposeWindowInset?.()
104
108
  document.documentElement.removeAttribute(ACTIVE_ATTR)
105
109
  root?.unmount()
106
110
  container?.remove()
@@ -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,
@@ -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[]
@@ -185,7 +188,7 @@ export class BoardController {
185
188
  if (this.state.selectedId !== undefined) {
186
189
  selected = ledger.tasks.find(t => t.id === this.state.selectedId)
187
190
  }
188
- this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
191
+ this.setState({ archiveSessionsSupported: ledger.capabilities?.archiveSessions === true, ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
189
192
  if (this.seenRevision === undefined || ledger.revision >= this.seenRevision) break
190
193
  }
191
194
  } catch (error) {
@@ -422,10 +425,27 @@ export class BoardController {
422
425
  }
423
426
  }
424
427
 
428
+ /** Retry session archiving without repeating the task's terminal transition. */
429
+ async retryArchiveSessions(id: string): Promise<void> {
430
+ try {
431
+ if (this.client.archiveSessions === undefined) return
432
+ const result = await this.client.archiveSessions(id)
433
+ this.setState({ sessionArchive: { taskId: id, result } })
434
+ await this.refresh()
435
+ } catch (error) {
436
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
437
+ }
438
+ }
439
+
425
440
  /** Move a task (user surface: done allowed). */
426
- async move(id: string, ifVersion: number, status: string): Promise<void> {
441
+ async move(id: string, ifVersion: number, status: string, options?: { archiveSessions?: boolean }): Promise<void> {
427
442
  try {
428
- await this.client.move(id, { ifVersion, status })
443
+ const result = await this.client.move(id, {
444
+ ifVersion,
445
+ status,
446
+ ...(options?.archiveSessions !== undefined ? { archiveSessions: options.archiveSessions } : {}),
447
+ })
448
+ if (result.sessionArchive !== undefined) this.setState({ sessionArchive: { taskId: id, result: result.sessionArchive } })
429
449
  await this.refresh()
430
450
  } catch (error) {
431
451
  this.setState({ error: error instanceof Error ? error.message : String(error) })
@@ -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',
@@ -456,4 +464,14 @@ export const en: TaskboardDict = {
456
464
  'slash.skill.source-driven-development': 'Design from authoritative docs and source code',
457
465
  'slash.skill.spec-driven-development': 'Write clear technical specs before coding',
458
466
  'slash.skill.using-agent-skills': 'Discover and invoke agent skills dynamically',
467
+
468
+ // ── host-generated system comments (0.6.4; localized at render) ────
469
+ 'sys.execFailed': '[System] Execution failed: {error}; the task was returned to todo.',
470
+ 'sys.endedWithComment': '[System] The execution session ended with comments but was not moved to in review; the system moved it to in review automatically.',
471
+ '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.',
472
+ 'sys.sessionError': '[System] Session execution error: {error}; the task was returned to todo.',
473
+ 'sys.sessionDone': '[System] The session finished; automatically moved to in review.',
474
+ 'sys.cronDead': '[System] The cron expression {cron} has no trigger time within 4 years; scheduling disabled — fix the cron and re-enable.',
475
+ 'sys.mergeSingle': '[System] Branch {branch} merged into the main worktree (--no-ff).',
476
+ 'sys.mergeMulti': '[System] Branches merged per repo (--no-ff): {summary}',
459
477
  }
@@ -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': '⛔ 标记受阻',
@@ -458,6 +466,16 @@ export const zh = {
458
466
  'slash.skill.source-driven-development': '基于权威官方文档与源码进行设计实现',
459
467
  'slash.skill.spec-driven-development': '在编码前制定清晰的技术规范',
460
468
  'slash.skill.using-agent-skills': '发现并动态调用智能体各项专业技能',
469
+
470
+ // ── host-generated system comments (0.6.4; localized at render) ────
471
+ 'sys.execFailed': '[系统] 执行失败:{error};任务已退回待办。',
472
+ 'sys.endedWithComment': '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。',
473
+ 'sys.endedNoHandoff': '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。',
474
+ 'sys.sessionError': '[系统] 会话执行异常:{error};任务已退回待办。',
475
+ 'sys.sessionDone': '[系统] 会话执行完毕,已自动进入待验收。',
476
+ 'sys.cronDead': '[系统] 定时表达式 {cron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。',
477
+ 'sys.mergeSingle': '[系统] 分支 {branch} 已合并到主工作区(--no-ff)。',
478
+ 'sys.mergeMulti': '[系统] 分支已按仓库合并(--no-ff):{summary}',
461
479
  } as const
462
480
 
463
481
  /** The dictionary shape en.ts must match key-for-key. */
@@ -79,6 +79,14 @@ html[data-dsh-atb-active] .dshDesktopConversationSurface > *:not([data-dsh-atb-v
79
79
  html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
80
80
 
81
81
  .dsh-atb-board { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: 12px 16px; gap: 10px; box-sizing: border-box; }
82
+ /* #20: native Windows caption controls overlay older Desktop content. Reserve
83
+ * their vertical band, including wrapped toolbar rows. New Desktop layouts
84
+ * already start below it: subtract the actual view top to avoid double insets.
85
+ * Electron exposes titlebar-area env values; 36px covers the Desktop frame
86
+ * when that API is unavailable. Ordinary Web/macOS views never match. */
87
+ .dsh-atb-view[data-dsh-atb-windows] > .dsh-atb-board {
88
+ padding-top: max(12px, calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 36px) + 8px - var(--dsh-atb-viewport-top, 0px)));
89
+ }
82
90
  .dsh-atb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
83
91
  /* 0.6.5 / #19: dsh-better-sidebar 钉在视口右上角的常驻按钮簇(2×28px + 4px
84
92
  * gap,right:10px → 占视口右边 10~70px)。它对 DSH 原生会话头的避让契约是
@@ -357,7 +365,7 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
357
365
  .dsh-atb-movebtn[data-to="canceled"], .dsh-atb-movebtn[data-to="archived"] { opacity: .75; }
358
366
  .dsh-atb-movebtn[data-to="blocked"] { border-color: rgba(229,72,77,.45); }
359
367
  .dsh-atb-movebtn[data-to="blocked"]:hover { background: rgba(229,72,77,.1); }
360
- .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; }
361
369
  .dsh-atb-confirm-label { font-size: 11.5px; color: var(--dsw-text-secondary, gray); }
362
370
 
363
371
  .dsh-atb-section { font-size: 13px; display: flex; flex-direction: column; gap: 7px; }
@@ -538,7 +546,10 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
538
546
  color: var(--dsw-alias-label-secondary, gray);
539
547
  }
540
548
  .dsh-atb-req { color: var(--dsw-alias-state-error-primary, #e5484d); font-style: normal; }
541
- .dsh-atb-modal-body input, .dsh-atb-modal-body textarea, .dsh-atb-modal-body select {
549
+ /* Checkbox 排除:整行宽输入样式(width:100% + padding/border)特异性 (0,1,1) 高于
550
+ .dsh-atb-cke-box (0,1,0),曾把编辑表单清单行的勾选框拉满整行(勾选框画在行
551
+ 中央)、文本框挤扁。清单勾选框保持原生外观与 15px 布局。 */
552
+ .dsh-atb-modal-body input:not([type="checkbox"]), .dsh-atb-modal-body textarea, .dsh-atb-modal-body select {
542
553
  font: inherit; font-size: 13px; padding: 7px 10px; border-radius: 8px;
543
554
  width: 100%; box-sizing: border-box;
544
555
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35));
@@ -546,7 +557,7 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
546
557
  transition: border-color .12s ease, box-shadow .12s ease;
547
558
  }
548
559
  .dsh-atb-modal-body textarea { min-height: 64px; resize: vertical; }
549
- .dsh-atb-modal-body input:focus, .dsh-atb-modal-body textarea:focus, .dsh-atb-modal-body select:focus {
560
+ .dsh-atb-modal-body input:not([type="checkbox"]):focus, .dsh-atb-modal-body textarea:focus, .dsh-atb-modal-body select:focus {
550
561
  outline: none; border-color: var(--dsw-alias-brand-primary, #1f2328);
551
562
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary, #1f2328) 18%, transparent);
552
563
  }
@@ -0,0 +1,38 @@
1
+ /** Keep injected content below Windows Desktop's native caption controls. */
2
+ export function installWindowInset(view: HTMLElement): () => void {
3
+ const update = (): void => {
4
+ const platform = document.body.getAttribute('data-dsh-desktop-platform')
5
+ ?? new URLSearchParams(window.location.search).get('dsh-desktop-platform')
6
+ if (platform !== 'win32') {
7
+ delete view.dataset.dshAtbWindows
8
+ view.style.removeProperty('--dsh-atb-viewport-top')
9
+ return
10
+ }
11
+ view.dataset.dshAtbWindows = ''
12
+ view.style.setProperty('--dsh-atb-viewport-top', `${view.getBoundingClientRect().top}px`)
13
+ }
14
+ let frame: number | undefined
15
+ const schedule = (): void => {
16
+ if (frame !== undefined) return
17
+ frame = requestAnimationFrame(() => { frame = undefined; update() })
18
+ }
19
+ const mutations = new MutationObserver(schedule)
20
+ mutations.observe(document.body, {
21
+ attributes: true, childList: true, subtree: true,
22
+ attributeFilter: ['class', 'data-dsh-desktop-platform', 'data-dsh-desktop-mode', 'data-details-collapsed'],
23
+ })
24
+ mutations.observe(document.documentElement, { attributes: true, attributeFilter: ['data-dsh-atb-active'] })
25
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule)
26
+ resize?.observe(view)
27
+ if (view.parentElement !== null) resize?.observe(view.parentElement)
28
+ window.addEventListener('resize', schedule)
29
+ schedule()
30
+ return () => {
31
+ mutations.disconnect()
32
+ resize?.disconnect()
33
+ window.removeEventListener('resize', schedule)
34
+ if (frame !== undefined) cancelAnimationFrame(frame)
35
+ delete view.dataset.dshAtbWindows
36
+ view.style.removeProperty('--dsh-atb-viewport-top')
37
+ }
38
+ }
@@ -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
+ }
@@ -72,7 +72,7 @@ export interface ExecutionWorkspaceFace {
72
72
 
73
73
  /** Narrow event-bus face for settlement listening. */
74
74
  export interface EventsFace {
75
- onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void): () => void
75
+ onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void | Promise<void>): () => void
76
76
  }
77
77
 
78
78
  /** Everything the execution service needs. */
@@ -293,6 +293,8 @@ export class ExecutionService {
293
293
  task.comments.push({
294
294
  id: newCommentId(),
295
295
  body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
296
+ systemKey: 'sys.execFailed',
297
+ systemParams: { error: message.slice(0, 300) },
296
298
  version: 1,
297
299
  createdAt: this.deps.now(),
298
300
  })
@@ -598,6 +600,7 @@ export class ExecutionService {
598
600
  body: normalizeBody(commented
599
601
  ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
600
602
  : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
603
+ systemKey: commented ? 'sys.endedWithComment' : 'sys.endedNoHandoff',
601
604
  version: 1,
602
605
  createdAt: now,
603
606
  })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Host-side locale reading for the few user-facing HOST log lines (the
3
+ * gitignore suggestion). Everything else the user sees is localized by the
4
+ * GUI (client i18n dictionaries), and host-written ledger comments carry a
5
+ * `systemKey` that the GUI localizes at render — so this module is only for
6
+ * the raw `console.*` lines the host emits itself.
7
+ *
8
+ * Source: the DSH locale plugin persists the explicit language choice as
9
+ * `locale.preference` in `$DSH_HOME/settings.yaml` (loopback pages) and
10
+ * exposes it through the settings service. When the preference is absent the
11
+ * browser delegates, which the host cannot see — so we fall back to `en`,
12
+ * matching the client's own fallback (zh only when something asked for it).
13
+ *
14
+ * @module dsh-taskboard/host/locale
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+
18
+ /** The two locales the taskboard ships. */
19
+ export type HostLocale = 'zh' | 'en'
20
+
21
+ /** Narrow settings-service face this module consumes (stringly-typed soft access). */
22
+ interface SettingsFace {
23
+ get?: (ns: string) => unknown
24
+ }
25
+
26
+ /** The DSH locale settings section (`preference` carries the explicit choice). */
27
+ interface LocaleSettings {
28
+ preference?: unknown
29
+ }
30
+
31
+ /**
32
+ * Read the active GUI locale as a host-side hint. Absent / malformed settings
33
+ * (or no settings service in scope) fall back to `en` and never throw — a
34
+ * cosmetic log line must not break route boot.
35
+ */
36
+ export function activeHostLocale(ctx: Context): HostLocale {
37
+ try {
38
+ const settings = ctx.get('settings') as SettingsFace | undefined
39
+ const locale = settings?.get?.('locale') as LocaleSettings | undefined
40
+ return locale?.preference === 'zh' ? 'zh' : 'en'
41
+ } catch {
42
+ return 'en'
43
+ }
44
+ }