@linxin666/dsh-client-ui-task-board 0.4.1 → 0.4.2

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -0
  3. package/README.zh.md +24 -0
  4. package/lib/client.js +917 -81
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1774 -1420
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/locales.d.ts +33 -0
  12. package/lib/types/core/controller.d.ts +30 -1
  13. package/lib/types/core/subtask.d.ts +122 -0
  14. package/lib/types/core/tasks.d.ts +46 -3
  15. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  16. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  17. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  18. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  19. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  20. package/lib/types/host/agent-tools.d.ts +27 -0
  21. package/lib/types/host-ledger.d.ts +88 -4
  22. package/lib/types/host-runner.d.ts +39 -1
  23. package/lib/types/host-service.d.ts +47 -0
  24. package/lib/types/index.d.ts +83 -5
  25. package/lib/types/protocol.d.ts +14 -0
  26. package/package.json +23 -21
  27. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  28. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  29. package/src/client/board/NewTaskModal.tsx +40 -6
  30. package/src/client/board/TaskBoard.tsx +88 -8
  31. package/src/client/board/TaskCard.tsx +52 -2
  32. package/src/client/board/TaskDetail.tsx +124 -1
  33. package/src/client/board.module.css +96 -0
  34. package/src/client/host-api.ts +14 -4
  35. package/src/client/index.ts +11 -1
  36. package/src/client/locales.ts +70 -2
  37. package/src/core/controller.ts +69 -8
  38. package/src/core/store.ts +15 -0
  39. package/src/core/subtask.ts +286 -0
  40. package/src/core/tasks.ts +50 -2
  41. package/src/core/use-cases/task-archive.ts +46 -12
  42. package/src/core/use-cases/task-create.ts +42 -7
  43. package/src/core/use-cases/task-delete.ts +11 -1
  44. package/src/core/use-cases/task-parent.ts +64 -0
  45. package/src/core/use-cases/task-update.ts +3 -1
  46. package/src/host/agent-tools.ts +643 -0
  47. package/src/host-ledger.ts +323 -45
  48. package/src/host-runner.ts +141 -12
  49. package/src/host-service.ts +127 -10
  50. package/src/index.ts +181 -9
  51. package/src/protocol.ts +26 -2
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Link-subtask modal: attach an existing on-board root task under the open
3
+ * task. The candidate list mirrors the Host lineage gate (checkParentLink), so
4
+ * a link the Host would refuse is not offered in the first place; the Host
5
+ * still re-checks the action at submit time.
6
+ */
7
+ import { useState } from 'react'
8
+ import type { BoardController } from '../../core/controller.ts'
9
+ import { DEFAULT_SUBTASK_DEPTH, checkParentLink } from '../../core/subtask.ts'
10
+ import type { TaskRecord } from '../../core/tasks.ts'
11
+ import { t } from '../locales.ts'
12
+ import css from '../board.module.css'
13
+ import { STATUS_KEY } from './status-key.ts'
14
+
15
+ /** Link-subtask overlay props. */
16
+ export interface LinkSubtaskModalProps {
17
+ controller: BoardController
18
+ /** The task the picked card becomes a subtask of. */
19
+ parent: TaskRecord
20
+ onClose: () => void
21
+ }
22
+
23
+ /** Link-subtask overlay. */
24
+ export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskModalProps) {
25
+ const [pending, setPending] = useState(false)
26
+ const [error, setError] = useState<string | undefined>(undefined)
27
+ const snapshot = controller.getSnapshot()
28
+ const limit = snapshot.host?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
29
+ // Only root, on-board tasks can move under another parent; the lineage gate
30
+ // decides the rest (depth, cycle, archived parent).
31
+ const candidates = snapshot.tasks.filter(task =>
32
+ task.archivedAt === undefined
33
+ // A running task may already be a participant of another run group; the
34
+ // Host refuses the move, so it is not offered here.
35
+ && task.status !== 'running'
36
+ && task.parentId === undefined
37
+ && task.id !== parent.id
38
+ && checkParentLink(snapshot.tasks, task.id, parent.id, limit).ok,
39
+ )
40
+ const link = async (taskId: string): Promise<void> => {
41
+ setPending(true)
42
+ setError(undefined)
43
+ if (await controller.setParent(taskId, parent.id)) {
44
+ onClose()
45
+ return
46
+ }
47
+ setPending(false)
48
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
49
+ }
50
+ return (
51
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
52
+ <div className={css.modal} role="dialog" aria-label={t('link.subtask.title')}>
53
+ <h2 className={css.modalTitle}>{t('link.subtask.title')}</h2>
54
+ <p className={css.fieldHint}>{t('link.subtask.hint')}</p>
55
+ {candidates.length === 0
56
+ ? <p className={css.detailText}>{t('link.subtask.empty')}</p>
57
+ : (
58
+ <ul className={css.pickList}>
59
+ {candidates.map(task => (
60
+ <li key={task.id} className={css.pickRow}>
61
+ <span className={css.pickTitle}>{task.title}</span>
62
+ <span className={css.statusBadge} data-status={task.status}>{t(STATUS_KEY[task.status])}</span>
63
+ <button
64
+ type="button"
65
+ className={css.primaryButton}
66
+ disabled={pending}
67
+ onClick={() => { void link(task.id) }}
68
+ >
69
+ {t('link.subtask.confirm')}
70
+ </button>
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ )}
75
+ {error !== undefined && <p className={css.formError}>{error}</p>}
76
+ <footer className={css.modalFooter}>
77
+ <button type="button" className={css.ghostButton} onClick={onClose}>{t('new.cancel')}</button>
78
+ </footer>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
+ import { effectiveTaskPermission } from '../../core/subtask.ts'
9
10
  import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
10
11
  import { t, type TaskBoardKey } from '../locales.ts'
11
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
@@ -25,18 +26,30 @@ export interface NewTaskModalProps {
25
26
  defaultWorkspaceId?: string
26
27
  /** Optional callback after successful duplication (e.g. to archive source). */
27
28
  onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
29
+ /**
30
+ * Task the new card becomes a subtask of ("add subtask" from its detail
31
+ * view). The execution targets start from the parent's and the create action
32
+ * carries the parent link; unset targets inherit again at run time.
33
+ */
34
+ parentTask?: TaskRecord
28
35
  }
29
36
 
30
37
  /** New-task form overlay. */
31
- export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) {
38
+ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess, parentTask }: NewTaskModalProps) {
32
39
  const isDuplicate = initialTask !== undefined
33
40
  const [title, setTitle] = useState(initialTask?.title ?? '')
34
41
  const [description, setDescription] = useState(initialTask?.description ?? '')
35
42
  const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
36
- const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '')
37
- const [mode, setMode] = useState(initialTask?.mode ?? '')
43
+ // A subtask starts from its parent's execution contract (issue: subtask
44
+ // inheritance); each picker below still overrides it for this card. The
45
+ // permission stays UNSET by default: leaving it empty is what makes the Host
46
+ // inherit the parent's binding together with its human confirmation, while
47
+ // picking a value pins this card's own binding and re-arms the gate.
48
+ const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? parentTask?.workspaceId ?? defaultWorkspaceId ?? '')
49
+ const [mode, setMode] = useState(initialTask?.mode ?? parentTask?.mode ?? '')
38
50
  const [permission, setPermission] = useState(initialTask?.permission ?? '')
39
- const [model, setModel] = useState(initialTask?.model ?? '')
51
+ const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
52
+ const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
40
53
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
41
54
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
42
55
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
@@ -62,6 +75,13 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
62
75
  const [parseError, setParseError] = useState<string | undefined>(undefined)
63
76
  const parseAbort = useRef<AbortController | undefined>(undefined)
64
77
  const parseModels = options.models ?? []
78
+ // A pinned target may have disappeared from the runtime (workspace deleted,
79
+ // preset removed) — especially when the form starts from a subtask's parent.
80
+ // Keep it selectable as a stale row, so the field shows what the create
81
+ // action will actually send instead of silently displaying the default.
82
+ const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
83
+ const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
84
+ const modelKnown = model === '' || parseModels.some(item => item.id === model)
65
85
 
66
86
  // The workspace list and preset roster arrive from the runtime after mount;
67
87
  // follow them so the pickers never freeze on an empty snapshot.
@@ -147,6 +167,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
147
167
  title,
148
168
  description,
149
169
  prompt,
170
+ ...(parentTask === undefined ? {} : { parentId: parentTask.id }),
150
171
  freeze,
151
172
  handover,
152
173
  workspaceId: workspaceId === '' ? undefined : workspaceId,
@@ -185,7 +206,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
185
206
  ? nextRunAtMs(scheduleCron, Date.now())
186
207
  : undefined
187
208
 
188
- const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new')
209
+ const modalTitle = parentTask !== undefined
210
+ ? t('new.subtaskTitle')
211
+ : isDuplicate ? t('new.duplicateTitle') : t('board.new')
189
212
 
190
213
  return (
191
214
  <ModalShell
@@ -246,6 +269,12 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
246
269
  </section>
247
270
  )}
248
271
 
272
+ {parentTask !== undefined && (
273
+ <p className={css.detailText} data-dsh-part="subtask-inherit">
274
+ {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
275
+ </p>
276
+ )}
277
+
249
278
  <TaskContentFields
250
279
  title={title}
251
280
  description={description}
@@ -290,6 +319,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
290
319
  onChange={event => { setWorkspaceId(event.target.value) }}
291
320
  >
292
321
  <option value="">{t('exec.workspace.recent')}</option>
322
+ {!workspaceKnown && <option value={workspaceId}>{workspaceId}{t('exec.mode.removed')}</option>}
293
323
  {options.workspaces.map(workspace => (
294
324
  <option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
295
325
  ))}
@@ -304,6 +334,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
304
334
  onChange={event => { setMode(event.target.value) }}
305
335
  >
306
336
  <option value="">{t('exec.mode.default')}</option>
337
+ {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
307
338
  {options.presets.map(preset => (
308
339
  <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
309
340
  {preset.name ?? preset.id}
@@ -321,7 +352,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
321
352
  value={permission}
322
353
  onChange={event => { setPermission(event.target.value) }}
323
354
  >
324
- <option value="">{t('exec.permission.default')}</option>
355
+ <option value="">{inheritedPermission === undefined
356
+ ? t('exec.permission.default')
357
+ : t('exec.permission.inheritParent', { permission: t(`exec.permission.${inheritedPermission}` as TaskBoardKey) })}</option>
325
358
  {TASK_PERMISSIONS.map(id => (
326
359
  <option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
327
360
  ))}
@@ -336,6 +369,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
336
369
  onChange={event => { setModel(event.target.value) }}
337
370
  >
338
371
  <option value="">{t('exec.model.default')}</option>
372
+ {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
339
373
  {options.models?.map(item => (
340
374
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
341
375
  ))}
@@ -43,9 +43,31 @@ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]):
43
43
  * re-renders only when its own task changes — not when a sibling card status,
44
44
  * the filter, or the selection moves.
45
45
  */
46
- const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen }: { task: TaskRecord; pending: boolean; timeZone?: string; onOpen: (id: string) => void }) {
46
+ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen, subtaskCount, isSubtask, subtasksDone, subtasksRunning, subtasksFailed }: {
47
+ task: TaskRecord
48
+ pending: boolean
49
+ timeZone?: string
50
+ onOpen: (id: string) => void
51
+ subtaskCount: number
52
+ isSubtask: boolean
53
+ subtasksDone: number
54
+ subtasksRunning: number
55
+ subtasksFailed: number
56
+ }) {
47
57
  const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen])
48
- return <TaskCard task={task} pending={pending} timeZone={timeZone} onClick={onClick} />
58
+ return (
59
+ <TaskCard
60
+ task={task}
61
+ pending={pending}
62
+ timeZone={timeZone}
63
+ onClick={onClick}
64
+ subtaskCount={subtaskCount}
65
+ isSubtask={isSubtask}
66
+ subtasksDone={subtasksDone}
67
+ subtasksRunning={subtasksRunning}
68
+ subtasksFailed={subtasksFailed}
69
+ />
70
+ )
49
71
  })
50
72
 
51
73
  /** Board component; subscribes to the controller snapshot. */
@@ -57,6 +79,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
57
79
  )
58
80
  const [filter, setFilter] = useState('')
59
81
  const [tagFilter, setTagFilter] = useState<string[]>([])
82
+ // Subtask cards are real board cards, but a tree with dozens of children
83
+ // turns a column into a wall: the board starts from the parent cards only and
84
+ // the header switch reveals the flat view. A text or label filter re-enables
85
+ // them automatically, so searching a subtask title still finds it.
86
+ const [hideSubtasks, setHideSubtasks] = useState(true)
60
87
  const [showNew, setShowNew] = useState(false)
61
88
  // Project partition (#1536): '' means "all projects". A selected project
62
89
  // narrows the board and becomes the new-task form's default workspace.
@@ -70,10 +97,29 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
70
97
  // Every label in use across the ledger (board and archive alike), so the
71
98
  // filter never loses an option just because its task was archived.
72
99
  const knownTags = collectKnownTags(snapshot.tasks)
73
- // Archived tasks leave the columns; the archive view shows them instead.
74
- const visible = snapshot.tasks.filter(task =>
75
- (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
76
- && (projectId === '' || task.workspaceId === projectId)
100
+ // The family of cards this view owns: the board columns, or the archive.
101
+ const onBoard = snapshot.tasks.filter(task =>
102
+ archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined,
103
+ )
104
+ // Direct subtask count and state roll-up per task, for the card badge: a
105
+ // hidden tree still has to report how many children run, fail, or finish.
106
+ const subtaskCounts = new Map<string, number>()
107
+ const subtaskRollup = new Map<string, { done: number; running: number; failed: number }>()
108
+ for (const task of onBoard) {
109
+ if (task.parentId === undefined) continue
110
+ subtaskCounts.set(task.parentId, (subtaskCounts.get(task.parentId) ?? 0) + 1)
111
+ const rollup = subtaskRollup.get(task.parentId) ?? { done: 0, running: 0, failed: 0 }
112
+ if (task.status === 'done') rollup.done += 1
113
+ else if (task.status === 'running') rollup.running += 1
114
+ else if (task.status === 'failed') rollup.failed += 1
115
+ subtaskRollup.set(task.parentId, rollup)
116
+ }
117
+ const hasSubtasks = subtaskCounts.size > 0
118
+ const searchActive = filter.trim() !== '' || tagFilter.length > 0
119
+ const hidingSubtasks = hideSubtasks && !searchActive
120
+ const visible = onBoard.filter(task =>
121
+ (projectId === '' || task.workspaceId === projectId)
122
+ && (!hidingSubtasks || task.parentId === undefined)
77
123
  && matchesFilter(task, filter)
78
124
  && matchesTagFilter(task, tagFilter),
79
125
  )
@@ -159,6 +205,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
159
205
  onChange={event => { setFilter(event.target.value) }}
160
206
  aria-label={t('board.search')}
161
207
  />
208
+ {hasSubtasks && (
209
+ <button
210
+ type="button"
211
+ className={hidingSubtasks ? css.primaryButton : css.ghostButton}
212
+ data-dsh-part="subtask-filter"
213
+ aria-pressed={hidingSubtasks}
214
+ title={t('board.subtaskFilterHint')}
215
+ onClick={() => { setHideSubtasks(value => !value) }}
216
+ >
217
+ {hidingSubtasks ? t('board.showSubtasks') : t('board.hideSubtasks')}
218
+ </button>
219
+ )}
162
220
  <button
163
221
  type="button"
164
222
  className={archiveView ? css.primaryButton : css.ghostButton}
@@ -257,7 +315,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
257
315
  </header>
258
316
  <div className={css.cards}>
259
317
  {visible.map(task => (
260
- <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
318
+ <MemoTaskCard
319
+ key={task.id}
320
+ task={task}
321
+ pending={snapshot.pendingTaskIds.includes(task.id)}
322
+ timeZone={snapshot.host?.scheduler.timeZone}
323
+ onOpen={openTask}
324
+ subtaskCount={subtaskCounts.get(task.id) ?? 0}
325
+ isSubtask={task.parentId !== undefined}
326
+ subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
327
+ subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
328
+ subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
329
+ />
261
330
  ))}
262
331
  {visible.length === 0 && (
263
332
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')}</div>
@@ -295,7 +364,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
295
364
  </header>
296
365
  <div className={css.cards}>
297
366
  {tasks.map(task => (
298
- <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
367
+ <MemoTaskCard
368
+ key={task.id}
369
+ task={task}
370
+ pending={snapshot.pendingTaskIds.includes(task.id)}
371
+ timeZone={snapshot.host?.scheduler.timeZone}
372
+ onOpen={openTask}
373
+ subtaskCount={subtaskCounts.get(task.id) ?? 0}
374
+ isSubtask={task.parentId !== undefined}
375
+ subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
376
+ subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
377
+ subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
378
+ />
299
379
  ))}
300
380
  {tasks.length === 0 && (
301
381
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')}</div>
@@ -37,7 +37,32 @@ export function formatTime(ms: number, timeZone?: string): string {
37
37
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
38
38
  }
39
39
 
40
- function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
40
+ function TaskCardInner({
41
+ task,
42
+ pending,
43
+ timeZone,
44
+ onClick,
45
+ subtaskCount = 0,
46
+ isSubtask = false,
47
+ subtasksDone = 0,
48
+ subtasksRunning = 0,
49
+ subtasksFailed = 0,
50
+ }: {
51
+ task: TaskRecord
52
+ pending: boolean
53
+ timeZone?: string
54
+ onClick: () => void
55
+ /** Direct subtasks of this card, shown as a badge (0 hides it). */
56
+ subtaskCount?: number
57
+ /** Whether this card is itself a subtask. */
58
+ isSubtask?: boolean
59
+ /** Direct subtasks already settled as done, for the roll-up badge. */
60
+ subtasksDone?: number
61
+ /** Direct subtasks currently running, for the roll-up badge. */
62
+ subtasksRunning?: number
63
+ /** Direct subtasks that failed, for the roll-up badge. */
64
+ subtasksFailed?: number
65
+ }) {
41
66
  const latest = task.executions[task.executions.length - 1]
42
67
  const runs = task.executions.length
43
68
  const archived = task.archivedAt !== undefined
@@ -77,6 +102,29 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
77
102
  )}
78
103
  {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
79
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
+ )}
80
128
  <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
81
129
  {task.freeze !== undefined && (
82
130
  <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
@@ -103,7 +151,9 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
103
151
  </span>
104
152
  {!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
105
153
  {!archived && latest !== undefined && executionLabel(latest) === 'running' && (
106
- <span className={css.cardRunningLabel}>{t('detail.result.running')}…</span>
154
+ <span className={css.cardRunningLabel}>
155
+ {latest.ownResult === undefined ? t('detail.result.running') : t('detail.subtasks.waiting')}…
156
+ </span>
107
157
  )}
108
158
  </button>
109
159
  )
@@ -10,11 +10,13 @@ import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
11
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
12
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
+ import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
13
14
  import { t, type TaskBoardKey } from '../locales.ts'
14
15
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
15
16
  import css from '../board.module.css'
16
17
  import { ConfirmDialog } from './ConfirmDialog.tsx'
17
18
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
19
+ import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
18
20
  import { NewTaskModal } from './NewTaskModal.tsx'
19
21
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
20
22
  import { STATUS_KEY } from './status-key.ts'
@@ -63,8 +65,16 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
63
65
  /** The execution-target editor: workspace / mode / permission pickers. */
64
66
  function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
65
67
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
68
+ // Whether this deployment serves the Agent Teams service: without it a team
69
+ // run is refused, so the opt-in is offered read-only with the reason.
70
+ const teamRunOffered = (): boolean => controller.getSnapshot().host?.teamRunAvailable === true
71
+ const [teamRunAvailable, setTeamRunAvailable] = useState(teamRunOffered())
66
72
  useEffect(
67
- () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
73
+ () => controller.subscribe(() => {
74
+ const snapshot = controller.getSnapshot()
75
+ setOptions(snapshot.executionOptions)
76
+ setTeamRunAvailable(snapshot.host?.teamRunAvailable === true)
77
+ }),
68
78
  [controller],
69
79
  )
70
80
  const workspaceId = task.workspaceId ?? ''
@@ -154,6 +164,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
154
164
  <span>{t('exec.reuseSession')}</span>
155
165
  </label>
156
166
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
167
+ <label className={css.scheduleToggle}>
168
+ <input
169
+ type="checkbox"
170
+ checked={task.teamRun === true}
171
+ disabled={pending || !teamRunAvailable}
172
+ onChange={event => { controller.updateTask(task.id, { teamRun: event.target.checked }) }}
173
+ />
174
+ <span>{t('exec.teamRun')}</span>
175
+ </label>
176
+ <p className={css.detailText}>{teamRunAvailable ? t('exec.teamRunHint') : t('exec.teamRunUnavailable')}</p>
157
177
  </section>
158
178
  )
159
179
  }
@@ -265,6 +285,103 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
265
285
  )
266
286
  }
267
287
 
288
+ /**
289
+ * The subtask block: the parent link, the direct subtasks with their status,
290
+ * and the actions that grow or prune the tree. Every gate here mirrors the
291
+ * Host lineage gate for affordance only; the Host re-checks the action.
292
+ */
293
+ function SubtaskSection({ controller, task, pending, archived }: {
294
+ controller: BoardController
295
+ task: TaskRecord
296
+ pending: boolean
297
+ archived: boolean
298
+ }) {
299
+ const [snapshot, setSnapshot] = useState(controller.getSnapshot())
300
+ useEffect(
301
+ () => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
302
+ [controller],
303
+ )
304
+ const [showAdd, setShowAdd] = useState(false)
305
+ const [showLink, setShowLink] = useState(false)
306
+ const tasks = snapshot.tasks
307
+ const parent = task.parentId === undefined ? undefined : tasks.find(candidate => candidate.id === task.parentId)
308
+ const children = directSubtasks(tasks, task.id)
309
+ const limit = snapshot.host?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
310
+ // A new subtask is a leaf, so it fits whenever one more level is allowed.
311
+ const canAddChild = taskDepth(tasks, task.id) + 1 <= limit
312
+ const editable = !archived && !pending
313
+ return (
314
+ <section className={css.detailSection} data-dsh-part="subtasks">
315
+ <h4>{t('detail.subtasks')}</h4>
316
+ {parent !== undefined && (
317
+ <p className={css.detailText}>
318
+ {t('detail.parent')}:{' '}
319
+ <button
320
+ type="button"
321
+ className={css.linkButton}
322
+ title={t('detail.parent.open')}
323
+ onClick={() => { controller.openTask(parent.id) }}
324
+ >
325
+ {parent.title}
326
+ </button>
327
+ </p>
328
+ )}
329
+ {children.length === 0
330
+ ? <p className={css.detailText}>{t('detail.subtasks.empty')}</p>
331
+ : (
332
+ <ul className={css.subtaskList}>
333
+ {children.map(child => (
334
+ <li key={child.id} className={css.subtaskRow}>
335
+ <button
336
+ type="button"
337
+ className={css.linkButton}
338
+ onClick={() => { controller.openTask(child.id) }}
339
+ >
340
+ {child.title}
341
+ </button>
342
+ <span className={css.statusBadge} data-status={child.status}>{t(STATUS_KEY[child.status])}</span>
343
+ {!archived && (
344
+ <button
345
+ type="button"
346
+ className={css.linkButton}
347
+ disabled={pending || child.status === 'running'}
348
+ title={child.status === 'running' ? t('detail.subtasks.runningLock') : undefined}
349
+ onClick={() => { void controller.setParent(child.id, null) }}
350
+ >
351
+ {t('detail.subtasks.detach')}
352
+ </button>
353
+ )}
354
+ </li>
355
+ ))}
356
+ </ul>
357
+ )}
358
+ {children.length > 0 && (
359
+ <p className={css.detailText}>{t('detail.subtasks.runHint', { count: String(children.length) })}</p>
360
+ )}
361
+ {!archived && (canAddChild
362
+ ? (controller.isHostBacked()
363
+ ? (
364
+ <div className={css.subtaskAddRow}>
365
+ <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowAdd(true) }}>
366
+ + {t('detail.subtasks.add')}
367
+ </button>
368
+ <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowLink(true) }}>
369
+ {t('detail.subtasks.link')}
370
+ </button>
371
+ </div>
372
+ )
373
+ : <p className={css.detailMeta}>{t('detail.subtasks.hostOnly')}</p>)
374
+ : <p className={css.detailMeta}>{t('detail.subtasks.depthLimit', { depth: String(limit) })}</p>)}
375
+ {showAdd && (
376
+ <NewTaskModal controller={controller} parentTask={task} onClose={() => { setShowAdd(false) }} />
377
+ )}
378
+ {showLink && (
379
+ <LinkSubtaskModal controller={controller} parent={task} onClose={() => { setShowLink(false) }} />
380
+ )}
381
+ </section>
382
+ )
383
+ }
384
+
268
385
  /** Task detail overlay. */
269
386
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
270
387
  const [confirmDelete, setConfirmDelete] = useState(false)
@@ -289,6 +406,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
289
406
  const transportError = snapshot.transportError
290
407
  const timeZone = snapshot.host?.scheduler.timeZone
291
408
  const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
409
+ const subtaskChildren = directSubtasks(snapshot.tasks, current.id)
292
410
 
293
411
  return (
294
412
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
@@ -322,6 +440,8 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
322
440
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
323
441
  </section>
324
442
 
443
+ <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} />
444
+
325
445
  {current.tags !== undefined && current.tags.length > 0 && (
326
446
  <section className={css.detailSection} data-dsh-part="tags">
327
447
  <h4>{t('new.tags')}</h4>
@@ -479,6 +599,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
479
599
  type="button"
480
600
  className={css.primaryButton}
481
601
  disabled={running || pending}
602
+ title={subtaskChildren.length > 0
603
+ ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
604
+ : undefined}
482
605
  onClick={() => {
483
606
  void controller.rerunTask(current.id).then(() => {
484
607
  if (controller.getSnapshot().transportError === undefined) controller.closeTask()