@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.
Files changed (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  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 +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/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -1,17 +1,31 @@
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'
15
29
 
16
30
  /**
17
31
  * Built formatters, keyed by time zone ('' = the browser's own). Constructing
@@ -56,11 +70,43 @@ export function formatTime(ms: number, timeZone?: string): string {
56
70
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
57
71
  }
58
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
+
59
103
  function TaskCardInner({
60
104
  task,
61
105
  pending,
62
106
  timeZone,
63
107
  onClick,
108
+ onOpenSession,
109
+ compact = false,
64
110
  subtaskCount = 0,
65
111
  isSubtask = false,
66
112
  subtasksDone = 0,
@@ -71,6 +117,10 @@ function TaskCardInner({
71
117
  pending: boolean
72
118
  timeZone?: string
73
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
74
124
  /** Direct subtasks of this card, shown as a badge (0 hides it). */
75
125
  subtaskCount?: number
76
126
  /** Whether this card is itself a subtask. */
@@ -82,16 +132,70 @@ function TaskCardInner({
82
132
  /** Direct subtasks that failed, for the roll-up badge. */
83
133
  subtasksFailed?: number
84
134
  }) {
85
- const latest = task.executions[task.executions.length - 1]
135
+ const { cardDecoration } = useTaskBoardSeats()
86
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])
87
140
  const archived = task.archivedAt !== undefined
88
- 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)
89
153
 
90
- 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 ? (
91
172
  <button
92
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
93
195
  className={css.card}
94
196
  data-status={archived ? 'archived' : task.status}
197
+ data-tone={outcome.tone}
198
+ data-compact={compact || undefined}
95
199
  data-dsh-part="card"
96
200
  data-pending={pending || undefined}
97
201
  draggable={isDraggable}
@@ -99,82 +203,111 @@ function TaskCardInner({
99
203
  event.dataTransfer.setData('text/plain', task.id)
100
204
  event.dataTransfer.effectAllowed = 'move'
101
205
  } : undefined}
102
- onClick={onClick}
103
- 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
+ }}
104
213
  >
105
- <span className={css.cardTitle}>{task.title}</span>
106
- {task.tags !== undefined && task.tags.length > 0 && (
107
- <span className={css.cardTags}>
108
- {task.tags.map(tag => (
109
- <span
110
- key={tag.name}
111
- className={css.cardTag}
112
- data-tag-tone={tagTone(tag.name)}
113
- data-dsh-part="tag-badge"
114
- data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
115
- title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
116
- >
117
- {tag.name}
118
- </span>
119
- ))}
120
- </span>
121
- )}
122
- {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
123
- <span className={css.cardMeta}>
124
- {isSubtask && (
125
- <span className={css.cardSubtask} data-dsh-part="subtask-badge">{t('card.subtask')}</span>
126
- )}
127
- {subtaskCount > 0 && (
128
- // A board that hides subtask cards still has to report them: the badge
129
- // rolls up the direct children and takes the tone of the worst state,
130
- // so a hidden failing tree cannot look green.
131
- <span
132
- className={css.cardSubtask}
133
- data-dsh-part="subtask-count"
134
- data-tone={subtasksFailed > 0 ? 'failed' : subtasksRunning > 0 ? 'running' : subtasksDone === subtaskCount ? 'done' : undefined}
135
- title={t('card.subtasksBreakdown', {
136
- total: String(subtaskCount),
137
- done: String(subtasksDone),
138
- running: String(subtasksRunning),
139
- failed: String(subtasksFailed),
140
- })}
141
- >
142
- {t('card.subtasks', { count: String(subtaskCount) })}
143
- {subtasksFailed > 0 ? ' · ' + t('card.subtasksFailed', { count: String(subtasksFailed) }) : ''}
144
- {subtasksFailed === 0 && subtasksRunning > 0 ? ' · ' + t('card.subtasksRunning', { count: String(subtasksRunning) }) : ''}
145
- </span>
146
- )}
147
- <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
148
- {task.freeze !== undefined && (
149
- <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
150
- )}
151
- {!archived && task.schedule?.enabled === true && (
152
- <span
153
- className={css.cardSchedule}
154
- title={task.schedule.nextRunAt !== undefined
155
- ? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
156
- : t('card.scheduled')}
157
- >
158
- {t('card.scheduled')}
159
- </span>
160
- )}
161
- {latest !== undefined && (
162
- <span className={css.cardRun} data-result={archived ? undefined : latest.result}>
163
- {runs} {t('board.runs')}
164
- </span>
165
- )}
166
- {latest?.sessionId !== undefined && (
167
- <span className={css.cardSession} title={latest.sessionId}>⌁</span>
168
- )}
169
- {!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>
170
218
  </span>
171
- {!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
172
- {!archived && latest !== undefined && executionLabel(latest) === 'running' && (
173
- <span className={css.cardRunningLabel}>
174
- {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" />}
175
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
+ </>
176
309
  )}
177
- </button>
310
+ </article>
178
311
  )
179
312
  }
180
313
 
@@ -4,10 +4,12 @@
4
4
  * delete (with confirmation), manual status moves, and a jump to the
5
5
  * execution's session transcript.
6
6
  */
7
- import { useEffect, useState } from 'react'
7
+ import { useCallback, useEffect, useState } from 'react'
8
8
  import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
9
+ import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
10
+ import { useTaskBoardSeats } from '../seats.tsx'
9
11
  import { isValidCron } from '../../core/schedule.ts'
10
- import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
12
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
13
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
14
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
15
  import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
@@ -17,11 +19,15 @@ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
17
19
  import css from '../board.module.css'
18
20
  import { ConfirmDialog } from './ConfirmDialog.tsx'
19
21
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
22
+ import { IconClose, IconPlus, IconSession } from './icons.tsx'
20
23
  import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
21
24
  import { NewTaskModal } from './NewTaskModal.tsx'
25
+ import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
22
26
  import { inheritPresetLabel, presetLabel } from './preset-label.ts'
23
27
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
24
28
  import { STATUS_KEY } from './status-key.ts'
29
+ import { VerificationReport } from './VerificationReport.tsx'
30
+ import { ClampedMarkdown } from './ClampedMarkdown.tsx'
25
31
 
26
32
  /** Execution outcome → locale key. */
27
33
  const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
@@ -47,6 +53,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
47
53
  {t('detail.execution.initiator', { session: execution.initiatedBy })}
48
54
  </span>
49
55
  )}
56
+ {execution.verification !== undefined && <VerificationReport verification={execution.verification} />}
50
57
  {execution.sessionId !== undefined && (
51
58
  <button
52
59
  type="button"
@@ -54,7 +61,8 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
54
61
  onClick={() => { onOpen(execution.sessionId as string) }}
55
62
  title={execution.sessionId}
56
63
  >
57
- {t('detail.viewSession')} ⌁
64
+ {t('detail.viewSession')}
65
+ <IconSession size={13} />
58
66
  </button>
59
67
  )}
60
68
  {execution.error !== undefined && execution.error !== '' && (
@@ -172,6 +180,16 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
172
180
  <span>{t('exec.goalRun')}</span>
173
181
  </label>
174
182
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
183
+ <label className={css.scheduleToggle}>
184
+ <input
185
+ type="checkbox"
186
+ checked={task.skipVerification === true}
187
+ disabled={pending}
188
+ onChange={event => { controller.updateTask(task.id, { skipVerification: event.target.checked }) }}
189
+ />
190
+ <span>{t('exec.skipVerification')}</span>
191
+ </label>
192
+ <p className={css.detailText}>{t('exec.skipVerificationHint')}</p>
175
193
  <label className={css.scheduleToggle}>
176
194
  <input
177
195
  type="checkbox"
@@ -347,6 +365,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
347
365
  }) {
348
366
  const [showAdd, setShowAdd] = useState(false)
349
367
  const [showLink, setShowLink] = useState(false)
368
+ const addPresence = usePresence(showAdd)
369
+ const linkPresence = usePresence(showLink)
350
370
  const tasks = snapshot.tasks
351
371
  const parent = task.parentId === undefined ? undefined : tasks.find(candidate => candidate.id === task.parentId)
352
372
  const children = directSubtasks(tasks, task.id)
@@ -388,8 +408,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
388
408
  <button
389
409
  type="button"
390
410
  className={css.linkButton}
391
- disabled={pending || child.status === 'running'}
392
- title={child.status === 'running' ? t('detail.subtasks.runningLock') : undefined}
411
+ disabled={pending || hasOpenExecution(child)}
412
+ title={hasOpenExecution(child) ? t('detail.subtasks.runningLock') : undefined}
393
413
  onClick={() => { void controller.setParent(child.id, null) }}
394
414
  >
395
415
  {t('detail.subtasks.detach')}
@@ -407,7 +427,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
407
427
  ? (
408
428
  <div className={css.subtaskAddRow}>
409
429
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowAdd(true) }}>
410
- + {t('detail.subtasks.add')}
430
+ <IconPlus size={14} />
431
+ {t('detail.subtasks.add')}
411
432
  </button>
412
433
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowLink(true) }}>
413
434
  {t('detail.subtasks.link')}
@@ -416,18 +437,19 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
416
437
  )
417
438
  : <p className={css.detailMeta}>{t('detail.subtasks.hostOnly')}</p>)
418
439
  : <p className={css.detailMeta}>{t('detail.subtasks.depthLimit', { depth: String(limit) })}</p>)}
419
- {showAdd && (
420
- <NewTaskModal controller={controller} parentTask={task} onClose={() => { setShowAdd(false) }} />
440
+ {addPresence.mounted && (
441
+ <NewTaskModal controller={controller} parentTask={task} phase={addPresence.phase} onClose={() => { setShowAdd(false) }} />
421
442
  )}
422
- {showLink && (
423
- <LinkSubtaskModal controller={controller} parent={task} onClose={() => { setShowLink(false) }} />
443
+ {linkPresence.mounted && (
444
+ <LinkSubtaskModal controller={controller} parent={task} phase={linkPresence.phase} onClose={() => { setShowLink(false) }} />
424
445
  )}
425
446
  </section>
426
447
  )
427
448
  }
428
449
 
450
+
429
451
  /** Task detail overlay. */
430
- export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
452
+ export function TaskDetail({ controller, task, phase = 'open' }: { controller: BoardController; task: TaskRecord; phase?: OverlayPhase }) {
431
453
  const [confirmDelete, setConfirmDelete] = useState(false)
432
454
  const [showEdit, setShowEdit] = useState(false)
433
455
  const [showEditTags, setShowEditTags] = useState(false)
@@ -443,18 +465,36 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
443
465
  setShowDuplicate(false)
444
466
  }, [task.id])
445
467
  const current = latest
468
+ const seats = useTaskBoardSeats()
469
+ const dispatch = useCallback(
470
+ (request: TaskBoardExtensionActionRequest): Promise<boolean> => controller.dispatchExtension(request),
471
+ [controller],
472
+ ) as TaskBoardExtensionDispatch
446
473
  const snapshot = controller.getSnapshot()
447
- const running = current.status === 'running'
474
+ // Busy means the runner is executing this card, which is an execution the
475
+ // detail view can see; the column alone never locks a card.
476
+ const busy = hasOpenExecution(current)
448
477
  const archived = current.archivedAt !== undefined
449
478
  const pending = snapshot.pendingTaskIds.includes(current.id)
450
479
  const transportError = snapshot.transportError
451
480
  const timeZone = snapshot.host?.scheduler.timeZone
452
481
  const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
453
482
  const subtaskChildren = directSubtasks(snapshot.tasks, current.id)
483
+ const dialog = useDialog<HTMLDivElement>(() => { controller.closeTask() }, phase)
484
+ // One presence per nested overlay: the detail view sits under up to four of
485
+ // them, and each has to run its own exit leg instead of the surface being
486
+ // pulled out of the DOM the moment its flag flips.
487
+ const editOpen = showEdit && !archived && canEditTaskContent(current)
488
+ const tagsOpen = showEditTags && !archived && !busy
489
+ const duplicateOpen = showDuplicate && !archived
490
+ const confirmPresence = usePresence(confirmDelete)
491
+ const editPresence = usePresence(editOpen)
492
+ const tagsPresence = usePresence(tagsOpen)
493
+ const duplicatePresence = usePresence(duplicateOpen)
454
494
 
455
495
  return (
456
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
457
- <div className={css.detail} role="dialog" aria-label={t('detail.title')}>
496
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
497
+ <div ref={dialog.attach} className={css.detail} role="dialog" aria-modal="true" aria-label={t('detail.title')} tabIndex={-1}>
458
498
  <header className={css.detailHeader}>
459
499
  <h2 className={css.detailTitle}>{current.title}</h2>
460
500
  <span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
@@ -464,9 +504,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
464
504
  type="button"
465
505
  className={css.iconButton}
466
506
  aria-label={t('detail.close')}
507
+ title={t('detail.close')}
467
508
  onClick={() => { controller.closeTask() }}
468
509
  >
469
- ×
510
+ <IconClose size={16} />
470
511
  </button>
471
512
  </header>
472
513
 
@@ -481,7 +522,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
481
522
  )}
482
523
  <section className={css.detailSection}>
483
524
  <h4>{t('detail.description')}</h4>
484
- <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
525
+ <ClampedMarkdown source={current.description !== '' ? current.description : '—'} />
485
526
  </section>
486
527
 
487
528
  <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
@@ -555,9 +596,11 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
555
596
  <p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
556
597
  )}
557
598
 
599
+ {seats.detailSection({ task: current, dispatch })}
600
+
558
601
  <section className={css.detailSection}>
559
602
  <h4>{t('detail.prompt')}</h4>
560
- <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
603
+ <ClampedMarkdown source={current.prompt !== '' ? current.prompt : current.title} />
561
604
  </section>
562
605
 
563
606
  {!archived && (
@@ -600,7 +643,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
600
643
  key={status}
601
644
  type="button"
602
645
  className={css.ghostButton}
603
- disabled={current.status === status || running || pending}
646
+ disabled={pending || !canMoveTask(current, status)}
604
647
  onClick={() => { controller.moveTask(current.id, status) }}
605
648
  >
606
649
  {t(`status.move.${status}` as TaskBoardKey)}
@@ -623,7 +666,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
623
666
  {t('detail.edit')}
624
667
  </button>
625
668
  )}
626
- {!archived && !canEditTaskContent(current) && current.status !== 'running' && (
669
+ {!archived && !canEditTaskContent(current) && !busy && (
627
670
  <button
628
671
  type="button"
629
672
  className={css.ghostButton}
@@ -648,7 +691,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
648
691
  <button
649
692
  type="button"
650
693
  className={css.primaryButton}
651
- disabled={running || pending}
694
+ disabled={busy || pending}
652
695
  title={subtaskChildren.length > 0
653
696
  ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
654
697
  : undefined}
@@ -701,12 +744,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
701
744
  </footer>
702
745
  </div>
703
746
 
704
- {confirmDelete && (
747
+ {confirmPresence.mounted && (
705
748
  <ConfirmDialog
706
749
  title={t('delete.title')}
707
750
  message={t('delete.confirm', { name: current.title })}
708
751
  confirmLabel={t('delete.ok')}
709
752
  danger
753
+ phase={confirmPresence.phase}
710
754
  onCancel={() => { setConfirmDelete(false) }}
711
755
  onConfirm={() => {
712
756
  setConfirmDelete(false)
@@ -715,18 +759,19 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
715
759
  />
716
760
  )}
717
761
 
718
- {showEdit && !archived && canEditTaskContent(current) && (
719
- <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
762
+ {editPresence.mounted && (
763
+ <EditTaskModal controller={controller} task={current} phase={editPresence.phase} onClose={() => { setShowEdit(false) }} />
720
764
  )}
721
765
 
722
- {showEditTags && !archived && current.status !== 'running' && (
723
- <EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
766
+ {tagsPresence.mounted && (
767
+ <EditTagsModal controller={controller} task={current} phase={tagsPresence.phase} onClose={() => { setShowEditTags(false) }} />
724
768
  )}
725
769
 
726
- {showDuplicate && !archived && (
770
+ {duplicatePresence.mounted && (
727
771
  <NewTaskModal
728
772
  controller={controller}
729
773
  initialTask={current}
774
+ phase={duplicatePresence.phase}
730
775
  onClose={() => { setShowDuplicate(false) }}
731
776
  onDuplicateSuccess={async (sourceId) => {
732
777
  await controller.archiveTask(sourceId)
@@ -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
  )