@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5
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 +42 -11
- package/README.zh.md +42 -11
- package/lib/client.js +4958 -472
- package/lib/client.js.map +1 -1
- package/lib/index.js +2841 -121
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -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/board-card-seat.d.ts +34 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +103 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- 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 +37 -1
- 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/tasks.d.ts +84 -3
- 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.d.ts +386 -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 +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +52 -8
- package/lib/types/host-runner.d.ts +23 -1
- package/lib/types/host-service.d.ts +88 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +49 -5
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- 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 +17 -1
- 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 +71 -26
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +203 -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 +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +43 -5
- package/src/client/locales.ts +208 -4
- package/src/client/native-panel.tsx +18 -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 +94 -3
- 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/store.ts +16 -0
- package/src/core/tasks.ts +100 -6
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +10 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +20 -18
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +143 -32
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +60 -11
- package/src/host-service.ts +253 -10
- package/src/index.ts +204 -16
- package/src/protocol.ts +102 -8
- package/src/tool-surface.ts +95 -0
|
@@ -0,0 +1,203 @@
|
|
|
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_QUALITY_ATTEMPTS,
|
|
17
|
+
exceptionAttempts,
|
|
18
|
+
qualityAttempts,
|
|
19
|
+
verificationPhase,
|
|
20
|
+
verificationTotals,
|
|
21
|
+
type ExecutionVerification,
|
|
22
|
+
type VerificationAttempt,
|
|
23
|
+
} from '../../core/verification.ts'
|
|
24
|
+
import { t } from '../locales.ts'
|
|
25
|
+
import type { TaskBoardKey } from '../locales.ts'
|
|
26
|
+
import css from '../board.module.css'
|
|
27
|
+
|
|
28
|
+
/** Render one 0..1 score as a percentage. */
|
|
29
|
+
function percent(value: number): string {
|
|
30
|
+
return (value * 100).toFixed(1) + '%'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The judge route of one attempt, with its effective reasoning level. */
|
|
34
|
+
function routeLabel(attempt: VerificationAttempt): string {
|
|
35
|
+
const model = attempt.route.provider === '' ? attempt.route.model : attempt.route.provider + '/' + attempt.route.model
|
|
36
|
+
const effort = attempt.route.reasoningEffort
|
|
37
|
+
const key: TaskBoardKey = effort === undefined || effort === '' ? 'verify.judgeNoEffort' : 'verify.judge'
|
|
38
|
+
return t(key, effort === undefined || effort === '' ? { model } : { model, effort })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Board phase → report badge key. */
|
|
42
|
+
const PHASE_STATUS_KEY: Record<ReturnType<typeof verificationPhase>, TaskBoardKey> = {
|
|
43
|
+
off: 'verify.status.off',
|
|
44
|
+
executing: 'verify.status.pending',
|
|
45
|
+
verifying: 'verify.status.verifying',
|
|
46
|
+
repairing: 'verify.status.failed',
|
|
47
|
+
passed: 'verify.status.passed',
|
|
48
|
+
failed: 'verify.status.failed',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One recorded attempt: a quality verdict or an acceptance anomaly. */
|
|
52
|
+
function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
|
|
53
|
+
const exception = attempt.stage === 'exception'
|
|
54
|
+
const result = exception ? 'failed' : attempt.passed ? 'succeeded' : 'failed'
|
|
55
|
+
return (
|
|
56
|
+
<li className={css.executionRow} data-result={result}>
|
|
57
|
+
<span className={css.executionBadge} data-result={result}>
|
|
58
|
+
{exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
|
|
59
|
+
</span>
|
|
60
|
+
<span className={css.executionTimes}>
|
|
61
|
+
{exception
|
|
62
|
+
? t('verify.attemptException', { index: String(attempt.index) })
|
|
63
|
+
: t('verify.attempt', { index: String(attempt.index) })}
|
|
64
|
+
{' · '}{routeLabel(attempt)}
|
|
65
|
+
{!exception && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
|
|
66
|
+
</span>
|
|
67
|
+
{attempt.error !== undefined && <span className={css.executionTimes}>{attempt.error}</span>}
|
|
68
|
+
{!exception && (
|
|
69
|
+
<>
|
|
70
|
+
<span className={css.executionTimes}>
|
|
71
|
+
{t('verify.summary', {
|
|
72
|
+
score: percent(attempt.score),
|
|
73
|
+
threshold: percent(attempt.criteria[0]?.threshold ?? 0),
|
|
74
|
+
baseline: percent(attempt.baseline),
|
|
75
|
+
})}
|
|
76
|
+
</span>
|
|
77
|
+
<span className={css.executionTimes}>{t('verify.criteria')}</span>
|
|
78
|
+
<ul className={css.executionList}>
|
|
79
|
+
{attempt.criteria.map(criterion => (
|
|
80
|
+
<li key={criterion.id} className={css.executionTimes}>
|
|
81
|
+
{t('verify.criterion', {
|
|
82
|
+
name: criterion.name,
|
|
83
|
+
score: percent(criterion.score),
|
|
84
|
+
threshold: percent(criterion.threshold),
|
|
85
|
+
})}
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
{attempt.findings.length > 0 && (
|
|
92
|
+
<>
|
|
93
|
+
<span className={css.executionTimes}>{t('verify.findings')}</span>
|
|
94
|
+
<ul className={css.executionList}>
|
|
95
|
+
{attempt.findings.map((finding, index) => (
|
|
96
|
+
<li key={findingKey(attempt, index)} className={css.executionTimes}>{finding}</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
{!exception && attempt.findings.length === 0 && (
|
|
102
|
+
<span className={css.executionTimes}>{t('verify.noFindings')}</span>
|
|
103
|
+
)}
|
|
104
|
+
<span className={css.executionTimes}>
|
|
105
|
+
{t('verify.evidence', {
|
|
106
|
+
chars: String(attempt.evidence.chars),
|
|
107
|
+
entries: String(attempt.evidence.entries),
|
|
108
|
+
omitted: String(attempt.evidence.omittedCharacters),
|
|
109
|
+
})}
|
|
110
|
+
{attempt.evidence.workspaceFiles !== undefined && attempt.evidence.workspaceFiles > 0
|
|
111
|
+
? ' · ' + t('verify.workspaceEvidence', { files: String(attempt.evidence.workspaceFiles) })
|
|
112
|
+
: ''}
|
|
113
|
+
</span>
|
|
114
|
+
<span className={css.executionTimes}>
|
|
115
|
+
{t('verify.usage', {
|
|
116
|
+
calls: String(attempt.usage.calls),
|
|
117
|
+
input: String(attempt.usage.inputTokens),
|
|
118
|
+
output: String(attempt.usage.outputTokens),
|
|
119
|
+
reasoning: String(attempt.usage.reasoningTokens),
|
|
120
|
+
})}
|
|
121
|
+
{attempt.usage.usageIncomplete === true ? t('verify.usageIncomplete') : ''}
|
|
122
|
+
</span>
|
|
123
|
+
</li>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Stable key for one finding row. */
|
|
128
|
+
function findingKey(attempt: VerificationAttempt, index: number): string {
|
|
129
|
+
return attempt.at.toString(36) + '-' + String(index)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render one execution's acceptance report.
|
|
134
|
+
* @param props - the execution's persisted acceptance block.
|
|
135
|
+
* @returns the report, or nothing when acceptance never applied.
|
|
136
|
+
*/
|
|
137
|
+
export function VerificationReport({ verification }: { verification: ExecutionVerification }) {
|
|
138
|
+
const phase = verificationPhase(verification)
|
|
139
|
+
if (phase === 'off' && verification.contract.enabled === false && verification.applicability !== 'skipped') return null
|
|
140
|
+
const totals = verificationTotals(verification)
|
|
141
|
+
const route = verification.contract.route
|
|
142
|
+
const applicabilityKey: TaskBoardKey | undefined = verification.applicability === 'disabled'
|
|
143
|
+
? 'verify.applicability.disabled'
|
|
144
|
+
: verification.applicability === 'skipped'
|
|
145
|
+
? 'verify.applicability.skipped'
|
|
146
|
+
: verification.applicability === 'goal-unavailable'
|
|
147
|
+
? 'verify.applicability.goalUnavailable'
|
|
148
|
+
: verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
|
|
149
|
+
return (
|
|
150
|
+
<div className={css.executionTimes}>
|
|
151
|
+
<span className={css.executionBadge} data-result={phase === 'passed' ? 'succeeded' : phase === 'failed' ? 'failed' : undefined}>
|
|
152
|
+
{t('verify.title')} · {t(PHASE_STATUS_KEY[phase])}
|
|
153
|
+
</span>
|
|
154
|
+
<span className={css.executionTimes}>
|
|
155
|
+
{t('verify.counts', {
|
|
156
|
+
quality: String(qualityAttempts(verification).length),
|
|
157
|
+
max: String(MAX_QUALITY_ATTEMPTS),
|
|
158
|
+
exceptions: String(exceptionAttempts(verification).length),
|
|
159
|
+
})}
|
|
160
|
+
</span>
|
|
161
|
+
{route !== undefined && (
|
|
162
|
+
<span className={css.executionTimes}>
|
|
163
|
+
{verification.contract.modelSource === 'inherit'
|
|
164
|
+
? t('verify.judgeInherited', { model: route.provider + '/' + route.model })
|
|
165
|
+
: t(route.reasoningEffort === undefined || route.reasoningEffort === '' ? 'verify.judgeNoEffort' : 'verify.judge', {
|
|
166
|
+
model: route.provider + '/' + route.model,
|
|
167
|
+
...(route.reasoningEffort === undefined ? {} : { effort: route.reasoningEffort }),
|
|
168
|
+
})}
|
|
169
|
+
</span>
|
|
170
|
+
)}
|
|
171
|
+
{verification.contract.effortFallback !== undefined && (
|
|
172
|
+
<span className={css.executionTimes}>
|
|
173
|
+
{t('verify.effortFallback', {
|
|
174
|
+
requested: verification.contract.effortFallback.requested,
|
|
175
|
+
resolved: verification.contract.effortFallback.resolved ?? t('settings.goalVerificationResolvedNoEffort'),
|
|
176
|
+
})}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
{applicabilityKey !== undefined && <span className={css.executionTimes}>{t(applicabilityKey)}</span>}
|
|
180
|
+
{verification.failedReason !== undefined && (
|
|
181
|
+
<span className={css.executionTimes}>{t('verify.finalFailure', { reason: verification.failedReason })}</span>
|
|
182
|
+
)}
|
|
183
|
+
{totals.calls > 0 && (
|
|
184
|
+
<span className={css.executionTimes}>
|
|
185
|
+
{t('verify.usage', {
|
|
186
|
+
calls: String(totals.calls),
|
|
187
|
+
input: String(totals.inputTokens),
|
|
188
|
+
output: String(totals.outputTokens),
|
|
189
|
+
reasoning: String(totals.reasoningTokens),
|
|
190
|
+
})}
|
|
191
|
+
{totals.usageIncomplete ? t('verify.usageIncomplete') : ''}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
{verification.attempts.length > 0 && (
|
|
195
|
+
<ul className={css.executionList}>
|
|
196
|
+
{[...verification.attempts].reverse().map(attempt => (
|
|
197
|
+
<AttemptRow key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)} attempt={attempt} />
|
|
198
|
+
))}
|
|
199
|
+
</ul>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's own vector glyphs.
|
|
3
|
+
*
|
|
4
|
+
* Every control glyph the board draws comes from here, so one action always
|
|
5
|
+
* has one metaphor and one stroke recipe: a 16-unit viewBox, `currentColor`,
|
|
6
|
+
* 1.3 stroke, round caps and joins — the same recipe the sidebar panel glyph
|
|
7
|
+
* (`native-panel.tsx`) uses, which is why the board's chrome and its sidebar
|
|
8
|
+
* row read as one product. Icon-only controls pass a localized `title` and an
|
|
9
|
+
* accessible name at the call site; the glyph itself is always decorative.
|
|
10
|
+
*
|
|
11
|
+
* @module @linxin666/dsh-client-ui-task-board/client/board/icons
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode, ReactElement } from 'react'
|
|
14
|
+
|
|
15
|
+
/** Stroke geometry shared by every board glyph. */
|
|
16
|
+
const GLYPH = {
|
|
17
|
+
viewBox: '0 0 16 16',
|
|
18
|
+
fill: 'none',
|
|
19
|
+
stroke: 'currentColor',
|
|
20
|
+
strokeWidth: 1.3,
|
|
21
|
+
strokeLinecap: 'round',
|
|
22
|
+
strokeLinejoin: 'round',
|
|
23
|
+
'aria-hidden': true,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
/** Props of one board glyph: the shared recipe plus an explicit pixel size. */
|
|
27
|
+
export interface BoardIconProps {
|
|
28
|
+
/** Rendered square edge in CSS pixels. */
|
|
29
|
+
size?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render one board glyph through the shared stroke recipe. */
|
|
33
|
+
function Glyph({ size = 14, children }: BoardIconProps & { children: ReactNode }): ReactElement {
|
|
34
|
+
return (
|
|
35
|
+
<svg {...GLYPH} width={size} height={size}>
|
|
36
|
+
{children}
|
|
37
|
+
</svg>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Leave the board: the back-to-chat chevron. */
|
|
42
|
+
export function IconChevronLeft(props: BoardIconProps): ReactElement {
|
|
43
|
+
return <Glyph {...props}><path d="M10 3.5 5.5 8 10 12.5" /></Glyph>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Dismiss a transient surface. */
|
|
47
|
+
export function IconClose(props: BoardIconProps): ReactElement {
|
|
48
|
+
return <Glyph {...props}><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" /></Glyph>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Add: create a task, a label row, or a subtask. */
|
|
52
|
+
export function IconPlus(props: BoardIconProps): ReactElement {
|
|
53
|
+
return <Glyph {...props}><path d="M8 3.5v9M3.5 8h9" /></Glyph>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Open the execution's session transcript. */
|
|
57
|
+
export function IconSession(props: BoardIconProps): ReactElement {
|
|
58
|
+
return (
|
|
59
|
+
<Glyph {...props}>
|
|
60
|
+
<rect x="2.2" y="3.2" width="11.6" height="9.6" rx="1.6" />
|
|
61
|
+
<path d="M5.3 6.6l1.9 1.7-1.9 1.7M8.8 10h2.4" />
|
|
62
|
+
</Glyph>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Switch a column to single-line cards. */
|
|
67
|
+
export function IconCompactRows(props: BoardIconProps): ReactElement {
|
|
68
|
+
return <Glyph {...props}><path d="M3 4.5h10M3 8h10M3 11.5h10" /></Glyph>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Switch a column back to full cards. */
|
|
72
|
+
export function IconExpandRows(props: BoardIconProps): ReactElement {
|
|
73
|
+
return (
|
|
74
|
+
<Glyph {...props}>
|
|
75
|
+
<rect x="2.6" y="2.6" width="10.8" height="4.4" rx="1.2" />
|
|
76
|
+
<rect x="2.6" y="9" width="10.8" height="4.4" rx="1.2" />
|
|
77
|
+
</Glyph>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A scheduled run: the recurrence mark on a card. */
|
|
82
|
+
export function IconClock(props: BoardIconProps): ReactElement {
|
|
83
|
+
return (
|
|
84
|
+
<Glyph {...props}>
|
|
85
|
+
<circle cx="8" cy="8" r="5.4" />
|
|
86
|
+
<path d="M8 5.2V8l2 1.4" />
|
|
87
|
+
</Glyph>
|
|
88
|
+
)
|
|
89
|
+
}
|