@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.
- package/README.i18n.yaml +2 -2
- package/README.md +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +46 -11
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +105 -10
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- 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}
|
|
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
|
-
|
|
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
|
+
}
|