@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -8,6 +8,8 @@ import { useState, type ReactNode } from 'react'
8
8
  import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
+ import { IconClose, IconPlus } from './icons.tsx'
12
+ import { useDialog, type OverlayPhase } from './overlay.tsx'
11
13
 
12
14
  /** DOM id shared by the tag-name inputs and their datalist (one board at a time). */
13
15
  const TAG_NAME_LIST_ID = 'dsh-task-board-tag-names'
@@ -22,6 +24,7 @@ export function ModalShell({
22
24
  onSubmit,
23
25
  onClose,
24
26
  secondaryAction,
27
+ phase = 'open',
25
28
  children,
26
29
  }: {
27
30
  ariaLabel: string
@@ -33,14 +36,20 @@ export function ModalShell({
33
36
  onClose: () => void
34
37
  /** Optional second action beside the primary submit (e.g. create and run). */
35
38
  secondaryAction?: { label: string; onSubmit: () => void }
39
+ /** Which leg of the enter/exit motion pair the surface is on. */
40
+ phase?: OverlayPhase
36
41
  children: ReactNode
37
42
  }) {
43
+ const dialog = useDialog<HTMLFormElement>(onClose, phase)
38
44
  return (
39
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
45
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
40
46
  <form
47
+ ref={dialog.attach}
41
48
  className={css.modal}
42
49
  role="dialog"
50
+ aria-modal="true"
43
51
  aria-label={ariaLabel}
52
+ tabIndex={-1}
44
53
  onSubmit={event => { event.preventDefault(); onSubmit() }}
45
54
  >
46
55
  <h2 className={css.modalTitle}>{title}</h2>
@@ -226,7 +235,7 @@ export function TaskTagFields({
226
235
  aria-label={t('new.tagRemove', { name: tag.name })}
227
236
  onClick={() => { onChange(tags.filter((_, position) => position !== index)) }}
228
237
  >
229
- ×
238
+ <IconClose size={14} />
230
239
  </button>
231
240
  </div>
232
241
  ))}
@@ -239,7 +248,8 @@ export function TaskTagFields({
239
248
  disabled={tags.length >= TASK_TAG_LIMIT}
240
249
  onClick={() => { onChange([...tags, { name: '' }]) }}
241
250
  >
242
- + {t('new.tagAdd')}
251
+ <IconPlus size={14} />
252
+ {t('new.tagAdd')}
243
253
  </button>
244
254
  </div>
245
255
  )
@@ -0,0 +1,322 @@
1
+ /**
2
+ * One execution's goal-acceptance report.
3
+ *
4
+ * Rendered inside the execution-history row, so a rerun or a scheduled run
5
+ * keeps its OWN report and a later cycle never overwrites an earlier verdict.
6
+ * It shows what the board decided and the evidence it decided on: the verdict,
7
+ * the judge route and reasoning level, the acceptance count, the total and
8
+ * per-criterion scores against their threshold, the findings the judge located,
9
+ * the evidence scope with its truncation, and the token usage.
10
+ *
11
+ * Deliberately adds no new `data-dsh-part` value: the part enum is owned by the
12
+ * cross-repository semantic-attribute contract, and this report reuses the
13
+ * execution row's existing markup instead of extending that enum.
14
+ */
15
+ import {
16
+ MAX_INVALID_ATTEMPTS,
17
+ MAX_QUALITY_ATTEMPTS,
18
+ exceptionAttempts,
19
+ invalidAttempts,
20
+ qualityAttempts,
21
+ verificationPhase,
22
+ verificationTotals,
23
+ type AcceptanceAuditRecord,
24
+ type AcceptanceCleanupRecord,
25
+ type ExecutionVerification,
26
+ type VerificationAttempt,
27
+ type VerificationCriterionFinding,
28
+ } from '../../core/verification.ts'
29
+ import { t } from '../locales.ts'
30
+ import type { TaskBoardKey } from '../locales.ts'
31
+ import css from '../board.module.css'
32
+
33
+ /** Render one 0..1 score as a percentage. */
34
+ function percent(value: number): string {
35
+ return (value * 100).toFixed(1) + '%'
36
+ }
37
+
38
+ /** The judge route of one attempt, with its effective reasoning level. */
39
+ function routeLabel(attempt: VerificationAttempt): string {
40
+ const model = attempt.route.provider === '' ? attempt.route.model : attempt.route.provider + '/' + attempt.route.model
41
+ const effort = attempt.route.reasoningEffort
42
+ const key: TaskBoardKey = effort === undefined || effort === '' ? 'verify.judgeNoEffort' : 'verify.judge'
43
+ return t(key, effort === undefined || effort === '' ? { model } : { model, effort })
44
+ }
45
+
46
+ /** Board phase → report badge key. */
47
+ const PHASE_STATUS_KEY: Record<ReturnType<typeof verificationPhase>, TaskBoardKey> = {
48
+ off: 'verify.status.off',
49
+ executing: 'verify.status.pending',
50
+ verifying: 'verify.status.verifying',
51
+ repairing: 'verify.status.failed',
52
+ passed: 'verify.status.passed',
53
+ failed: 'verify.status.failed',
54
+ invalid: 'verify.status.invalidHeld',
55
+ }
56
+
57
+ /** Evidence location → locale key, for one structured finding's citation. */
58
+ const LOCATION_KEY: Record<VerificationCriterionFinding['location'], TaskBoardKey> = {
59
+ task: 'verify.location.task',
60
+ trajectory: 'verify.location.trajectory',
61
+ workspace: 'verify.location.workspace',
62
+ baseline: 'verify.location.baseline',
63
+ unknown: 'verify.location.unknown',
64
+ }
65
+
66
+ /** Invalid reason → locale key. */
67
+ function invalidReasonKey(attempt: VerificationAttempt): TaskBoardKey {
68
+ switch (attempt.invalidReason) {
69
+ case 'missing-finding': return 'verify.invalidReason.missing-finding'
70
+ case 'unlocatable-quote': return 'verify.invalidReason.unlocatable-quote'
71
+ case 'baseline-finding': return 'verify.invalidReason.baseline-finding'
72
+ case 'vacuous-finding': return 'verify.invalidReason.vacuous-finding'
73
+ case 'insufficient-evidence': return 'verify.invalidReason.insufficient-evidence'
74
+ default: return 'verify.invalidReason.unknown'
75
+ }
76
+ }
77
+
78
+ /** Audit stage → locale key (the lightweight credential after cleanup). */
79
+ const AUDIT_STAGE_KEY: Record<AcceptanceAuditRecord['stage'], TaskBoardKey> = {
80
+ quality: 'verify.cleanup.stage.quality',
81
+ invalid: 'verify.cleanup.stage.invalid',
82
+ exception: 'verify.cleanup.stage.exception',
83
+ budget: 'verify.cleanup.stage.budget',
84
+ }
85
+
86
+ /** One recorded attempt: a quality verdict, an invalid veto, an anomaly, or a budget stop. */
87
+ function AttemptRow({ attempt, detailRemoved }: { attempt: VerificationAttempt, detailRemoved: boolean }) {
88
+ // Everything that is not a quality verdict renders as an anomaly row: a
89
+ // budget stop carries no score either, and an invalid acceptance carries a
90
+ // score the board REFUSED to book, so showing either as a scored failure
91
+ // would read as a judgement the board never made. The invalid row gets its
92
+ // own badge and reason line, because it is a distinct outcome.
93
+ const invalid = attempt.stage === 'invalid'
94
+ const exception = attempt.stage !== 'quality' && !invalid
95
+ const result = exception ? 'failed' : attempt.passed ? 'succeeded' : 'failed'
96
+ return (
97
+ <li className={css.executionRow} data-result={result}>
98
+ <span className={css.executionBadge} data-result={result}>
99
+ {invalid ? t('verify.status.invalid') : exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
100
+ </span>
101
+ <span className={css.executionTimes}>
102
+ {invalid
103
+ ? t('verify.attemptInvalid', { index: String(attempt.index) })
104
+ : exception
105
+ ? t('verify.attemptException', { index: String(attempt.index) })
106
+ : t('verify.attempt', { index: String(attempt.index) })}
107
+ {' · '}{routeLabel(attempt)}
108
+ {!exception && !invalid && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
109
+ </span>
110
+ {invalid && (
111
+ <span className={css.executionTimes}>
112
+ {t('verify.invalidDetail', { reason: t(invalidReasonKey(attempt)) })}
113
+ </span>
114
+ )}
115
+ {attempt.error !== undefined && <span className={css.executionTimes}>{attempt.error}</span>}
116
+ {!exception && (
117
+ <>
118
+ <span className={css.executionTimes}>
119
+ {t('verify.summary', {
120
+ score: percent(attempt.score),
121
+ threshold: percent(attempt.criteria[0]?.threshold ?? 0),
122
+ baseline: percent(attempt.baseline),
123
+ })}
124
+ </span>
125
+ <span className={css.executionTimes}>{t('verify.criteria')}</span>
126
+ <ul className={css.executionList}>
127
+ {attempt.criteria.map(criterion => (
128
+ <li key={criterion.id} className={css.executionTimes}>
129
+ {t('verify.criterion', {
130
+ name: criterion.name,
131
+ score: percent(criterion.score),
132
+ threshold: percent(criterion.threshold),
133
+ })}
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ </>
138
+ )}
139
+ {(attempt.criterionFindings ?? []).length > 0 && (
140
+ <>
141
+ <span className={css.executionTimes}>{t('verify.criterionFindings')}</span>
142
+ <ul className={css.executionList}>
143
+ {(attempt.criterionFindings ?? []).map((finding, index) => (
144
+ <li key={findingKey(attempt, index)} className={css.executionTimes}>
145
+ {t('verify.criterionFindingFull', {
146
+ criterion: finding.criterionId,
147
+ location: t(LOCATION_KEY[finding.location]),
148
+ quote: finding.quote,
149
+ requirement: finding.requirement,
150
+ observation: finding.observation,
151
+ gap: finding.gap,
152
+ })}
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ </>
157
+ )}
158
+ {attempt.findings.length > 0 && (
159
+ <>
160
+ <span className={css.executionTimes}>{t('verify.findings')}</span>
161
+ <ul className={css.executionList}>
162
+ {attempt.findings.map((finding, index) => (
163
+ <li key={findingKey(attempt, index)} className={css.executionTimes}>{finding}</li>
164
+ ))}
165
+ </ul>
166
+ </>
167
+ )}
168
+ {!exception && !invalid && attempt.findings.length === 0 && (
169
+ // A cleaned attempt has no findings because the acceptance removed them,
170
+ // not because the judge gave none: saying "the judge reported no
171
+ // finding" here would invite exactly the misreading the cleanup rule
172
+ // exists to avoid.
173
+ <span className={css.executionTimes}>{t(detailRemoved ? 'verify.detailRemoved' : 'verify.noFindings')}</span>
174
+ )}
175
+ <span className={css.executionTimes}>
176
+ {t('verify.evidence', {
177
+ chars: String(attempt.evidence.chars),
178
+ entries: String(attempt.evidence.entries),
179
+ omitted: String(attempt.evidence.omittedCharacters),
180
+ })}
181
+ {attempt.evidence.workspaceFiles !== undefined && attempt.evidence.workspaceFiles > 0
182
+ ? ' · ' + t('verify.workspaceEvidence', { files: String(attempt.evidence.workspaceFiles) })
183
+ : ''}
184
+ </span>
185
+ <span className={css.executionTimes}>
186
+ {t('verify.usage', {
187
+ calls: String(attempt.usage.calls),
188
+ input: String(attempt.usage.inputTokens),
189
+ output: String(attempt.usage.outputTokens),
190
+ reasoning: String(attempt.usage.reasoningTokens),
191
+ })}
192
+ {attempt.usage.usageIncomplete === true ? t('verify.usageIncomplete') : ''}
193
+ </span>
194
+ </li>
195
+ )
196
+ }
197
+
198
+ /** Stable key for one finding row. */
199
+ function findingKey(attempt: VerificationAttempt, index: number): string {
200
+ return attempt.at.toString(36) + '-' + String(index)
201
+ }
202
+
203
+ /**
204
+ * Render one execution's acceptance report.
205
+ * @param props - the execution's persisted acceptance block.
206
+ * @returns the report, or nothing when acceptance never applied.
207
+ */
208
+ export function VerificationReport({ verification }: { verification: ExecutionVerification }) {
209
+ const phase = verificationPhase(verification)
210
+ if (phase === 'off' && verification.contract.enabled === false && verification.applicability !== 'skipped') return null
211
+ const totals = verificationTotals(verification)
212
+ const route = verification.contract.route
213
+ const applicabilityKey: TaskBoardKey | undefined = verification.applicability === 'disabled'
214
+ ? 'verify.applicability.disabled'
215
+ : verification.applicability === 'skipped'
216
+ ? 'verify.applicability.skipped'
217
+ : verification.applicability === 'goal-unavailable'
218
+ ? 'verify.applicability.goalUnavailable'
219
+ : verification.applicability === 'goal-disabled'
220
+ ? 'verify.applicability.goalDisabled'
221
+ : verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
222
+ return (
223
+ <div className={css.executionTimes}>
224
+ <span className={css.executionBadge} data-result={phase === 'passed' ? 'succeeded' : phase === 'failed' ? 'failed' : undefined}>
225
+ {t('verify.title')} · {t(PHASE_STATUS_KEY[phase])}
226
+ </span>
227
+ <span className={css.executionTimes}>
228
+ {t('verify.invalidCounts', {
229
+ quality: String(qualityAttempts(verification).length),
230
+ max: String(MAX_QUALITY_ATTEMPTS),
231
+ invalid: String(invalidAttempts(verification).length),
232
+ maxInvalid: String(MAX_INVALID_ATTEMPTS),
233
+ exceptions: String(exceptionAttempts(verification).length),
234
+ })}
235
+ </span>
236
+ {route !== undefined && (
237
+ <span className={css.executionTimes}>
238
+ {verification.contract.modelSource === 'inherit'
239
+ ? t('verify.judgeInherited', { model: route.provider + '/' + route.model })
240
+ : t(route.reasoningEffort === undefined || route.reasoningEffort === '' ? 'verify.judgeNoEffort' : 'verify.judge', {
241
+ model: route.provider + '/' + route.model,
242
+ ...(route.reasoningEffort === undefined ? {} : { effort: route.reasoningEffort }),
243
+ })}
244
+ </span>
245
+ )}
246
+ {verification.contract.effortFallback !== undefined && (
247
+ <span className={css.executionTimes}>
248
+ {t('verify.effortFallback', {
249
+ requested: verification.contract.effortFallback.requested,
250
+ resolved: verification.contract.effortFallback.resolved ?? t('settings.goalVerificationResolvedNoEffort'),
251
+ })}
252
+ </span>
253
+ )}
254
+ {applicabilityKey !== undefined && <span className={css.executionTimes}>{t(applicabilityKey)}</span>}
255
+ {verification.failedReason !== undefined && (
256
+ <span className={css.executionTimes}>{t('verify.finalFailure', { reason: verification.failedReason })}</span>
257
+ )}
258
+ {totals.calls > 0 && (
259
+ <span className={css.executionTimes}>
260
+ {t('verify.usage', {
261
+ calls: String(totals.calls),
262
+ input: String(totals.inputTokens),
263
+ output: String(totals.outputTokens),
264
+ reasoning: String(totals.reasoningTokens),
265
+ })}
266
+ {totals.usageIncomplete ? t('verify.usageIncomplete') : ''}
267
+ </span>
268
+ )}
269
+ {verification.attempts.length > 0 && (
270
+ <ul className={css.executionList}>
271
+ {[...verification.attempts].reverse().map(attempt => (
272
+ <AttemptRow
273
+ key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)}
274
+ attempt={attempt}
275
+ detailRemoved={verification.cleanup?.state === 'cleaned'}
276
+ />
277
+ ))}
278
+ </ul>
279
+ )}
280
+ {verification.cleanup !== undefined && <CleanupNotice cleanup={verification.cleanup} />}
281
+ </div>
282
+ )
283
+ }
284
+
285
+ /**
286
+ * Retention notice of one execution's acceptance detail.
287
+ *
288
+ * It exists so a reader never mistakes a cleaned-up report for a judge that
289
+ * produced no evidence: the notice says the detail WAS produced and was removed
290
+ * by the automatic cleanup, and lists the lightweight credential that survives.
291
+ * A pending or failed cleanup is reported the same way — never as a missing
292
+ * verdict and never as a revoked pass.
293
+ */
294
+ function CleanupNotice({ cleanup }: { cleanup: AcceptanceCleanupRecord }) {
295
+ const key: TaskBoardKey = cleanup.state === 'cleaned'
296
+ ? 'verify.cleanup.cleaned'
297
+ : cleanup.state === 'failed' ? 'verify.cleanup.failed' : 'verify.cleanup.pending'
298
+ return (
299
+ <>
300
+ <span className={css.executionTimes}>
301
+ {cleanup.state === 'failed'
302
+ ? t(key, { attempts: String(cleanup.attempts), error: cleanup.lastError ?? '' })
303
+ : t(key)}
304
+ </span>
305
+ {cleanup.audit.length > 0 && (
306
+ <ul className={css.executionList}>
307
+ {cleanup.audit.map((record, index) => (
308
+ <li key={record.at.toString(36) + '-' + record.stage + '-' + String(index)} className={css.executionTimes}>
309
+ {t('verify.cleanup.audit', {
310
+ index: String(index + 1),
311
+ stage: t(AUDIT_STAGE_KEY[record.stage]),
312
+ score: percent(record.score),
313
+ hash: record.evidenceHash.slice(0, 12),
314
+ })}
315
+ {record.findingSummary === undefined ? '' : ' · ' + record.findingSummary}
316
+ </li>
317
+ ))}
318
+ </ul>
319
+ )}
320
+ </>
321
+ )
322
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Card view model: the pure derivations a task card renders from its record.
3
+ *
4
+ * Everything here is a function of the task record (plus the clock and the
5
+ * Host time zone for the recency groups), so the card stays a thin renderer and
6
+ * every rule is unit-testable without mounting React. Nothing here writes the
7
+ * record: the type prefix, the tone and the time label are presentation only.
8
+ *
9
+ * @module @linxin666/dsh-client-ui-task-board/client/board/card-view
10
+ */
11
+ import { hasOpenExecution, type ExecutionOutcome, type ExecutionRecord, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
12
+ import type { TaskBoardKey } from '../locales.ts'
13
+
14
+ /** A title split into its bracketed type prefix and the remaining text. */
15
+ export interface TitleParts {
16
+ /** Bracketed type prefix such as `Bug` from `[Bug]: ...`; absent when none. */
17
+ kind?: string
18
+ /** Title text without the prefix (the whole title when there is no prefix). */
19
+ text: string
20
+ }
21
+
22
+ /** `[Bug]:` / `[Issue]` / a bracketed CJK word, at most 24 characters, optional colon. */
23
+ const KIND_PREFIX = /^\[([^[\]\n]{1,24})\]\s*[:\uFF1A]?\s*/
24
+
25
+ /**
26
+ * Split an issue-form style type prefix off a title. The prefix becomes a
27
+ * badge, so the clamped title lines carry the text the reader scans for. A
28
+ * title that is only a prefix keeps its full text.
29
+ * @param summary - the plain-text title.
30
+ * @returns the prefix (when present) and the remaining title text.
31
+ */
32
+ export function splitTitleKind(summary: string): TitleParts {
33
+ const match = KIND_PREFIX.exec(summary)
34
+ if (match === null) return { text: summary }
35
+ const kind = (match[1] ?? '').trim()
36
+ const text = summary.slice(match[0].length).trim()
37
+ if (kind === '' || text === '') return { text: summary }
38
+ return { kind, text }
39
+ }
40
+
41
+ /** Stripe tone of a card: the state of its latest execution. */
42
+ export type CardTone = 'running' | ExecutionOutcome
43
+
44
+ /** What the card reports about its latest execution. */
45
+ export interface CardOutcome {
46
+ /** Stripe tone; absent on an archived card or a card that never ran. */
47
+ tone?: CardTone
48
+ /** The latest execution record, when the card ran at least once. */
49
+ latest?: ExecutionRecord
50
+ /**
51
+ * The column disagrees with the latest settled run: a card in Done whose last
52
+ * run did not succeed, or a card in Failed whose last run succeeded. The
53
+ * column was then set by hand or by a provider (a closed issue), and the
54
+ * card says so instead of letting a red run count read as a contradiction.
55
+ */
56
+ declared: boolean
57
+ }
58
+
59
+ /**
60
+ * Derive the card's execution outcome.
61
+ * @param task - the task record.
62
+ * @param archived - whether the card renders in the archive view.
63
+ */
64
+ export function cardOutcome(task: TaskRecord, archived: boolean): CardOutcome {
65
+ const latest = task.executions[task.executions.length - 1]
66
+ if (latest === undefined) return { declared: false }
67
+ if (hasOpenExecution(task)) return archived ? { latest, declared: false } : { tone: 'running', latest, declared: false }
68
+ const result = latest.result
69
+ if (archived || result === undefined) return { latest, declared: false }
70
+ const declared = (task.status === 'done' && result !== 'succeeded') || (task.status === 'failed' && result === 'succeeded')
71
+ return { tone: result, latest, declared }
72
+ }
73
+
74
+ /** Locale key of the latest settled result shown before the run count. */
75
+ export const RESULT_KEY: Record<ExecutionOutcome, TaskBoardKey> = {
76
+ succeeded: 'card.result.succeeded',
77
+ failed: 'card.result.failed',
78
+ cancelled: 'card.result.cancelled',
79
+ }
80
+
81
+ /** The one time a card shows, and what that time means. */
82
+ export interface CardTimeline {
83
+ /** Locale key with a `{time}` placeholder. */
84
+ key: TaskBoardKey
85
+ /** The instant (ms epoch). */
86
+ at: number
87
+ }
88
+
89
+ /**
90
+ * Pick the time that tells cards in the same column apart. `updatedAt` moves
91
+ * on every provider sync, so a column of synced cards would all read
92
+ * "updated 4m"; each column instead shows the instant its cards differ by:
93
+ * planning columns the creation time, an executing card its start, settled
94
+ * columns the latest run's settlement, the archive the archive time.
95
+ * @param task - the task record.
96
+ * @param archived - whether the card renders in the archive view.
97
+ */
98
+ export function cardTimeline(task: TaskRecord, archived: boolean): CardTimeline {
99
+ if (archived && task.archivedAt !== undefined) return { key: 'card.time.archived', at: task.archivedAt }
100
+ const open = task.executions.find(execution => execution.endedAt === undefined)
101
+ if (open !== undefined) return { key: 'card.time.started', at: open.startedAt }
102
+ if (task.status === 'done' || task.status === 'failed') {
103
+ const latest = task.executions[task.executions.length - 1]
104
+ if (latest?.endedAt !== undefined) return { key: 'card.time.settled', at: latest.endedAt }
105
+ return { key: 'card.time.updated', at: task.updatedAt }
106
+ }
107
+ if (task.status === 'backlog' || task.status === 'todo') return { key: 'card.time.created', at: task.createdAt }
108
+ return { key: 'card.time.updated', at: task.updatedAt }
109
+ }
110
+
111
+ /** Recency bucket of a settled card. */
112
+ export type RecencyGroup = 'today' | 'week' | 'earlier'
113
+
114
+ /** Group order, newest first. */
115
+ export const RECENCY_GROUPS: readonly RecencyGroup[] = ['today', 'week', 'earlier']
116
+
117
+ /** Locale key of each recency group header. */
118
+ export const RECENCY_KEY: Record<RecencyGroup, TaskBoardKey> = {
119
+ today: 'board.group.today',
120
+ week: 'board.group.week',
121
+ earlier: 'board.group.earlier',
122
+ }
123
+
124
+ const DAY_FORMATS = new Map<string, Intl.DateTimeFormat>()
125
+
126
+ /** Calendar day of an instant in a time zone (the browser zone when absent). */
127
+ function dayKey(ms: number, timeZone: string | undefined): string {
128
+ const key = timeZone ?? ''
129
+ let format = DAY_FORMATS.get(key)
130
+ if (format === undefined) {
131
+ try {
132
+ format = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', ...(timeZone === undefined ? {} : { timeZone }) })
133
+ } catch {
134
+ // An unknown zone id: bucket by the browser zone rather than failing the board.
135
+ format = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' })
136
+ }
137
+ DAY_FORMATS.set(key, format)
138
+ }
139
+ return format.format(new Date(ms))
140
+ }
141
+
142
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000
143
+
144
+ /**
145
+ * Bucket an instant: the same Host calendar day as now, the last seven days, or
146
+ * earlier.
147
+ * @param at - the instant to bucket.
148
+ * @param now - the current instant.
149
+ * @param timeZone - Host IANA zone the calendar day is read in.
150
+ */
151
+ export function recencyGroup(at: number, now: number, timeZone?: string): RecencyGroup {
152
+ if (dayKey(at, timeZone) === dayKey(now, timeZone)) return 'today'
153
+ if (now - at < WEEK_MS) return 'week'
154
+ return 'earlier'
155
+ }
156
+
157
+ /** One recency group and its items, newest first. */
158
+ export interface RecencyBucket<T> {
159
+ group: RecencyGroup
160
+ items: T[]
161
+ }
162
+
163
+ /**
164
+ * Sort items newest first and split them into recency groups; empty groups are
165
+ * omitted.
166
+ * @param items - the items to group.
167
+ * @param at - the instant each item is ordered and bucketed by.
168
+ * @param now - the current instant.
169
+ * @param timeZone - Host IANA zone.
170
+ */
171
+ export function groupByRecency<T>(items: readonly T[], at: (item: T) => number, now: number, timeZone?: string): RecencyBucket<T>[] {
172
+ const sorted = [...items].sort((left, right) => at(right) - at(left))
173
+ const buckets = new Map<RecencyGroup, T[]>()
174
+ for (const item of sorted) {
175
+ const group = recencyGroup(at(item), now, timeZone)
176
+ const list = buckets.get(group) ?? []
177
+ list.push(item)
178
+ buckets.set(group, list)
179
+ }
180
+ return RECENCY_GROUPS.filter(group => buckets.has(group)).map(group => ({ group, items: buckets.get(group)! }))
181
+ }
182
+
183
+ /** Columns that render compact by default: the long settled column. */
184
+ export const DEFAULT_COMPACT_COLUMNS: readonly TaskStatus[] = ['done']
185
+
186
+ /** Browser storage key of the per-column density choice. */
187
+ export const COMPACT_COLUMNS_KEY = 'dsh.taskBoard.compactColumns.v1'
188
+
189
+ const STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'running', 'done', 'failed']
190
+
191
+ /**
192
+ * Read the columns the user set to compact. A missing, unreadable or malformed
193
+ * value falls back to the default rather than failing the board.
194
+ * @param storage - browser storage (absent outside a browser).
195
+ */
196
+ export function readCompactColumns(storage: Pick<Storage, 'getItem'> | undefined): TaskStatus[] {
197
+ try {
198
+ const raw = storage?.getItem(COMPACT_COLUMNS_KEY)
199
+ if (raw === null || raw === undefined) return [...DEFAULT_COMPACT_COLUMNS]
200
+ const parsed: unknown = JSON.parse(raw)
201
+ if (!Array.isArray(parsed)) return [...DEFAULT_COMPACT_COLUMNS]
202
+ return STATUSES.filter(status => parsed.includes(status))
203
+ } catch {
204
+ return [...DEFAULT_COMPACT_COLUMNS]
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Persist the compact columns; a storage failure (quota, privacy mode) only
210
+ * loses the preference.
211
+ * @param storage - browser storage (absent outside a browser).
212
+ * @param columns - the compact columns.
213
+ */
214
+ export function writeCompactColumns(storage: Pick<Storage, 'setItem'> | undefined, columns: readonly TaskStatus[]): void {
215
+ try {
216
+ storage?.setItem(COMPACT_COLUMNS_KEY, JSON.stringify(STATUSES.filter(status => columns.includes(status))))
217
+ } catch {
218
+ // Preference only: the board keeps working with the in-memory choice.
219
+ }
220
+ }