@linxin666/dsh-client-ui-task-board 0.4.3 → 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.
Files changed (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -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 +23 -2
  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/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -1,27 +1,60 @@
1
1
  /**
2
- * Task card: the board's column item. Clicking opens the task detail — it
3
- * never executes anything directly (detail holds the Run button).
2
+ * Task card: the board's column item. The stretched open control opens the
3
+ * task detail — it never executes anything directly (detail holds the Run
4
+ * button).
5
+ *
6
+ * The card is an `article` with one stretched `button` rather than a button
7
+ * itself: interactive content inside a button is invalid HTML, and the card
8
+ * carries its own quick actions (open the execution session, a provider's
9
+ * link such as the tracker issue). The article owns the pointer click and
10
+ * ignores clicks that land on a quick action; the stretched open button is the
11
+ * keyboard and accessibility target, and its click bubbles into the same
12
+ * handler, so the detail opens exactly once either way.
4
13
  *
5
14
  * Memoized: the card re-renders only when its own task record changes, so a
6
15
  * status/filter update on one card (or scrolling) never re-renders every
7
- * card on the board. The per-card onClick is built with a stable task reference
8
- * by the board, so the memo boundary is effective.
16
+ * card on the board. The per-card callbacks are built with a stable task
17
+ * reference by the board, so the memo boundary is effective.
9
18
  */
10
- import { memo } from 'react'
19
+ import { memo, useMemo } from 'react'
11
20
  import type { TaskRecord } from '../../core/tasks.ts'
12
- import { executionLabel, tagTone } from '../../core/tasks.ts'
21
+ import { executionLabel, hasOpenExecution, tagTone } from '../../core/tasks.ts'
13
22
  import { t } from '../locales.ts'
23
+ import { useTaskBoardSeats } from '../seats.tsx'
24
+ import { cardOutcome, cardTimeline, RESULT_KEY, splitTitleKind } from './card-view.ts'
25
+ import { IconClock, IconSession } from './icons.tsx'
26
+ import { verificationRunningKey } from './status-key.ts'
14
27
  import css from '../board.module.css'
28
+ import { markdownExcerpt, markdownToPlainText } from './task-markdown.tsx'
29
+
30
+ /**
31
+ * Built formatters, keyed by time zone ('' = the browser's own). Constructing
32
+ * an Intl.DateTimeFormat is orders of magnitude more expensive than formatting
33
+ * with one, and the board renders dozens of timestamps per SSE frame; the
34
+ * cached formatter keeps that cost off the render path.
35
+ */
36
+ const HOST_TIMESTAMP_FORMATS = new Map<string, Intl.DateTimeFormat>()
37
+
38
+ function hostTimestampFormat(timeZone: string | undefined): Intl.DateTimeFormat {
39
+ const key = timeZone ?? ''
40
+ const cached = HOST_TIMESTAMP_FORMATS.get(key)
41
+ if (cached !== undefined) return cached
42
+ const format = new Intl.DateTimeFormat(undefined, {
43
+ dateStyle: 'medium',
44
+ timeStyle: 'medium',
45
+ ...(timeZone === undefined ? {} : { timeZone }),
46
+ })
47
+ HOST_TIMESTAMP_FORMATS.set(key, format)
48
+ return format
49
+ }
15
50
 
16
51
  /** Compact relative/absolute time label. */
17
52
  export function formatHostTimestamp(ms: number, timeZone?: string): string {
18
53
  try {
19
- return new Intl.DateTimeFormat(undefined, {
20
- dateStyle: 'medium',
21
- timeStyle: 'medium',
22
- ...(timeZone === undefined ? {} : { timeZone }),
23
- }).format(new Date(ms))
54
+ return hostTimestampFormat(timeZone).format(new Date(ms))
24
55
  } catch {
56
+ // An unsupported time-zone id throws on construction: never cache it, and
57
+ // fall back to the ISO instant for this call.
25
58
  return new Date(ms).toISOString()
26
59
  }
27
60
  }
@@ -37,11 +70,43 @@ export function formatTime(ms: number, timeZone?: string): string {
37
70
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
38
71
  }
39
72
 
73
+ /** Cached YYYY-MM-DD formatters, keyed by time zone ('' = the browser's own). */
74
+ const CARD_DATE_FORMATS = new Map<string, Intl.DateTimeFormat>()
75
+
76
+ /**
77
+ * Card time label: relative within a day, otherwise the calendar date read in
78
+ * the Host time zone (the same zone the tooltip and the detail view use), so a
79
+ * card never shows a browser-local date beside a Host-zone tooltip.
80
+ * @param ms - the instant.
81
+ * @param timeZone - Host IANA zone; absent uses the browser zone.
82
+ * @param now - the current instant.
83
+ */
84
+ export function formatCardTime(ms: number, timeZone?: string, now: number = Date.now()): string {
85
+ const minutes = Math.floor((now - ms) / 60000)
86
+ if (minutes < 1) return t('time.justNow')
87
+ if (minutes < 60) return `${minutes}m`
88
+ if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`
89
+ const key = timeZone ?? ''
90
+ try {
91
+ let format = CARD_DATE_FORMATS.get(key)
92
+ if (format === undefined) {
93
+ format = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', ...(timeZone === undefined ? {} : { timeZone }) })
94
+ CARD_DATE_FORMATS.set(key, format)
95
+ }
96
+ return format.format(new Date(ms))
97
+ } catch {
98
+ // An unsupported zone id: never cached; fall back to the UTC calendar date.
99
+ return new Date(ms).toISOString().slice(0, 10)
100
+ }
101
+ }
102
+
40
103
  function TaskCardInner({
41
104
  task,
42
105
  pending,
43
106
  timeZone,
44
107
  onClick,
108
+ onOpenSession,
109
+ compact = false,
45
110
  subtaskCount = 0,
46
111
  isSubtask = false,
47
112
  subtasksDone = 0,
@@ -52,6 +117,10 @@ function TaskCardInner({
52
117
  pending: boolean
53
118
  timeZone?: string
54
119
  onClick: () => void
120
+ /** Open an execution session; absent renders the session mark inert. */
121
+ onOpenSession?: (sessionId: string) => void
122
+ /** Single-line density used by long settled columns. */
123
+ compact?: boolean
55
124
  /** Direct subtasks of this card, shown as a badge (0 hides it). */
56
125
  subtaskCount?: number
57
126
  /** Whether this card is itself a subtask. */
@@ -63,16 +132,70 @@ function TaskCardInner({
63
132
  /** Direct subtasks that failed, for the roll-up badge. */
64
133
  subtasksFailed?: number
65
134
  }) {
66
- const latest = task.executions[task.executions.length - 1]
135
+ const { cardDecoration } = useTaskBoardSeats()
67
136
  const runs = task.executions.length
137
+ const summary = useMemo(() => markdownToPlainText(task.title) || task.title, [task.title])
138
+ const title = useMemo(() => splitTitleKind(summary), [summary])
139
+ const excerpt = useMemo(() => (compact ? '' : markdownExcerpt(task.description)), [task.description, compact])
68
140
  const archived = task.archivedAt !== undefined
69
- const isDraggable = !archived && task.status !== 'running' && !pending
141
+ // The lock is an open execution, not the column: a card parked in 'running'
142
+ // by hand stays draggable, and an executing card never is.
143
+ const busy = hasOpenExecution(task)
144
+ const isDraggable = !archived && !busy && !pending
145
+ const outcome = cardOutcome(task, archived)
146
+ const latest = outcome.latest
147
+ // Forced acceptance owns the running label.
148
+ const runningKey = verificationRunningKey(latest?.verification)
149
+ const timeline = cardTimeline(task, archived)
150
+ const resultText = latest?.result !== undefined ? t(RESULT_KEY[latest.result]) : ''
151
+ const sessionId = latest?.sessionId
152
+ const tagNames = (task.tags ?? []).map(tag => tag.name)
70
153
 
71
- return (
154
+ const ariaLabel = [
155
+ title.kind === undefined ? summary : `${title.kind}: ${title.text}`,
156
+ !archived && pending ? t('board.pending') : '',
157
+ !archived && busy ? t(runningKey ?? 'detail.result.running') : '',
158
+ !archived && !busy && resultText !== '' ? resultText : '',
159
+ outcome.declared ? t('card.declared') : '',
160
+ subtasksFailed > 0 ? t('card.subtasksFailed', { count: String(subtasksFailed) }) : '',
161
+ tagNames.length > 0 ? t('card.tagsLabel', { tags: tagNames.join(', ') }) : '',
162
+ ].filter(Boolean).join(' · ')
163
+ // The tooltip names what the clamped surface cannot show — the full title and
164
+ // the absolute instant — not the whole excerpt again.
165
+ const tooltip = [
166
+ summary,
167
+ t(timeline.key, { time: formatHostTimestamp(timeline.at, timeZone) }),
168
+ outcome.declared ? t('card.declaredHint') : '',
169
+ ].filter(Boolean).join('\n')
170
+
171
+ const sessionMark = sessionId !== undefined && (onOpenSession !== undefined ? (
72
172
  <button
73
173
  type="button"
174
+ className={`${css.cardSession} ${css.cardAction}`}
175
+ data-dsh-part="card-session"
176
+ title={t('card.openSession')}
177
+ aria-label={t('card.openSession')}
178
+ onClick={() => { onOpenSession(sessionId) }}
179
+ >
180
+ <IconSession size={14} />
181
+ </button>
182
+ ) : (
183
+ <span className={css.cardSession} title={sessionId}>
184
+ <IconSession size={12} />
185
+ </span>
186
+ ))
187
+ const decoration = <span className={css.cardDecoration}>{cardDecoration({ task })}</span>
188
+ const timeLabel = <span className={css.cardTime} data-dsh-part="card-time">{t(timeline.key, { time: formatCardTime(timeline.at, timeZone) })}</span>
189
+ const kindBadge = title.kind !== undefined && (
190
+ <span className={css.cardKind} data-dsh-part="card-kind">{title.kind}</span>
191
+ )
192
+
193
+ return (
194
+ <article
74
195
  className={css.card}
75
196
  data-status={archived ? 'archived' : task.status}
197
+ data-tone={outcome.tone}
198
+ data-compact={compact || undefined}
76
199
  data-dsh-part="card"
77
200
  data-pending={pending || undefined}
78
201
  draggable={isDraggable}
@@ -80,82 +203,111 @@ function TaskCardInner({
80
203
  event.dataTransfer.setData('text/plain', task.id)
81
204
  event.dataTransfer.effectAllowed = 'move'
82
205
  } : undefined}
83
- onClick={onClick}
84
- title={task.description !== '' ? task.description : task.title}
206
+ title={tooltip}
207
+ onClick={(event) => {
208
+ // A quick action (session, provider link) handles its own click.
209
+ const target = event.target as Element
210
+ if (target.closest('a, button:not([data-dsh-part="card-open"])') !== null) return
211
+ onClick()
212
+ }}
85
213
  >
86
- <span className={css.cardTitle}>{task.title}</span>
87
- {task.tags !== undefined && task.tags.length > 0 && (
88
- <span className={css.cardTags}>
89
- {task.tags.map(tag => (
90
- <span
91
- key={tag.name}
92
- className={css.cardTag}
93
- data-tag-tone={tagTone(tag.name)}
94
- data-dsh-part="tag-badge"
95
- data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
96
- title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
97
- >
98
- {tag.name}
99
- </span>
100
- ))}
101
- </span>
102
- )}
103
- {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
104
- <span className={css.cardMeta}>
105
- {isSubtask && (
106
- <span className={css.cardSubtask} data-dsh-part="subtask-badge">{t('card.subtask')}</span>
107
- )}
108
- {subtaskCount > 0 && (
109
- // A board that hides subtask cards still has to report them: the badge
110
- // rolls up the direct children and takes the tone of the worst state,
111
- // so a hidden failing tree cannot look green.
112
- <span
113
- className={css.cardSubtask}
114
- data-dsh-part="subtask-count"
115
- data-tone={subtasksFailed > 0 ? 'failed' : subtasksRunning > 0 ? 'running' : subtasksDone === subtaskCount ? 'done' : undefined}
116
- title={t('card.subtasksBreakdown', {
117
- total: String(subtaskCount),
118
- done: String(subtasksDone),
119
- running: String(subtasksRunning),
120
- failed: String(subtasksFailed),
121
- })}
122
- >
123
- {t('card.subtasks', { count: String(subtaskCount) })}
124
- {subtasksFailed > 0 ? ' · ' + t('card.subtasksFailed', { count: String(subtasksFailed) }) : ''}
125
- {subtasksFailed === 0 && subtasksRunning > 0 ? ' · ' + t('card.subtasksRunning', { count: String(subtasksRunning) }) : ''}
126
- </span>
127
- )}
128
- <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
129
- {task.freeze !== undefined && (
130
- <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
131
- )}
132
- {!archived && task.schedule?.enabled === true && (
133
- <span
134
- className={css.cardSchedule}
135
- title={task.schedule.nextRunAt !== undefined
136
- ? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
137
- : t('card.scheduled')}
138
- >
139
- {t('card.scheduled')}
140
- </span>
141
- )}
142
- {latest !== undefined && (
143
- <span className={css.cardRun} data-result={archived ? undefined : latest.result}>
144
- {runs} {t('board.runs')}
145
- </span>
146
- )}
147
- {latest?.sessionId !== undefined && (
148
- <span className={css.cardSession} title={latest.sessionId}>⌁</span>
149
- )}
150
- {!archived && (task.status === 'running' || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
214
+ <button type="button" className={css.cardOpen} data-dsh-part="card-open" aria-label={ariaLabel} />
215
+ <span className={css.cardTitleRow}>
216
+ {kindBadge}
217
+ <span className={css.cardTitle} data-dsh-part="card-title">{title.text}</span>
151
218
  </span>
152
- {!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
153
- {!archived && latest !== undefined && executionLabel(latest) === 'running' && (
154
- <span className={css.cardRunningLabel}>
155
- {latest.ownResult === undefined ? t('detail.result.running') : t('detail.subtasks.waiting')}…
219
+ {compact ? (
220
+ <span className={css.cardMeta}>
221
+ {decoration}
222
+ {timeLabel}
223
+ {!archived && (busy || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
156
224
  </span>
225
+ ) : (
226
+ <>
227
+ {task.tags !== undefined && task.tags.length > 0 && (
228
+ <span className={css.cardTags}>
229
+ {task.tags.map(tag => (
230
+ <span
231
+ key={tag.name}
232
+ className={css.cardTag}
233
+ data-tag-tone={tagTone(tag.name)}
234
+ data-dsh-part="tag-badge"
235
+ data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
236
+ title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
237
+ >
238
+ {tag.name}
239
+ </span>
240
+ ))}
241
+ </span>
242
+ )}
243
+ {excerpt !== '' && <span className={css.cardExcerpt} data-dsh-part="card-excerpt">{excerpt}</span>}
244
+ <span className={css.cardMeta}>
245
+ {decoration}
246
+ {isSubtask && (
247
+ <span className={css.cardSubtask} data-dsh-part="subtask-badge">{t('card.subtask')}</span>
248
+ )}
249
+ {subtaskCount > 0 && (
250
+ // A board that hides subtask cards still has to report them: the badge
251
+ // rolls up the direct children and takes the tone of the worst state,
252
+ // so a hidden failing tree cannot look green.
253
+ <span
254
+ className={css.cardSubtask}
255
+ data-dsh-part="subtask-count"
256
+ data-tone={subtasksFailed > 0 ? 'failed' : subtasksRunning > 0 ? 'running' : subtasksDone === subtaskCount ? 'done' : undefined}
257
+ title={t('card.subtasksBreakdown', {
258
+ total: String(subtaskCount),
259
+ done: String(subtasksDone),
260
+ running: String(subtasksRunning),
261
+ failed: String(subtasksFailed),
262
+ })}
263
+ >
264
+ {t('card.subtasks', { count: String(subtaskCount) })}
265
+ {subtasksFailed > 0 ? ' · ' + t('card.subtasksFailed', { count: String(subtasksFailed) }) : ''}
266
+ {subtasksFailed === 0 && subtasksRunning > 0 ? ' · ' + t('card.subtasksRunning', { count: String(subtasksRunning) }) : ''}
267
+ </span>
268
+ )}
269
+ {timeLabel}
270
+ {task.freeze !== undefined && (
271
+ <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
272
+ )}
273
+ {!archived && task.schedule?.enabled === true && (
274
+ <span
275
+ className={css.cardSchedule}
276
+ title={task.schedule.nextRunAt !== undefined
277
+ ? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
278
+ : t('card.scheduled')}
279
+ >
280
+ <IconClock size={12} />
281
+ {t('card.scheduled')}
282
+ </span>
283
+ )}
284
+ {latest !== undefined && (
285
+ <span
286
+ className={css.cardRun}
287
+ data-dsh-part="card-run"
288
+ data-result={archived || latest.result === undefined ? undefined : latest.result}
289
+ data-declared={outcome.declared || undefined}
290
+ title={outcome.declared ? t('card.declaredHint') : undefined}
291
+ >
292
+ {!busy && resultText !== '' ? `${resultText} · ` : ''}
293
+ {runs} {t('board.runs')}
294
+ {outcome.declared ? ` · ${t('card.declared')}` : ''}
295
+ </span>
296
+ )}
297
+ {sessionMark}
298
+ {!archived && (busy || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
299
+ </span>
300
+ {!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
301
+ {!archived && latest !== undefined && executionLabel(latest) === 'running' && (
302
+ <span className={css.cardRunningLabel}>
303
+ {runningKey !== undefined
304
+ ? t(runningKey)
305
+ : latest.ownResult === undefined ? t('detail.result.running') : t('detail.subtasks.waiting')}…
306
+ </span>
307
+ )}
308
+ </>
157
309
  )}
158
- </button>
310
+ </article>
159
311
  )
160
312
  }
161
313