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

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 (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  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/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. package/src/client/sidebar-entry.ts +0 -53
@@ -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()
@@ -4,135 +4,30 @@
4
4
  * board follows the active theme (light/dark and skins).
5
5
  */
6
6
 
7
- /* --- center-column takeover (global rules, attribute-scoped) ----------------- */
7
+ /* --- native panel page ------------------------------------------------------- */
8
8
 
9
- [data-pane='conversation'],
10
- [class*='centerCol'] {
11
- position: relative;
12
- }
9
+ /* The board is an official center-column panel: the layout mounts this element
10
+ as the selected keyed 'main' occupant, so the shell's own column already
11
+ owns position, sizing and the conversation underneath. Nothing here hides or
12
+ re-parents another plugin's surface — the previous DOM takeover (absolute
13
+ overlay over a hidden conversation subtree) is gone with it.
13
14
 
14
- /* The board container rides inside the conversation grid item as an extra
15
- trailing child; hidden unless the board is active. */
15
+ The container query context stays: the board's responsive rules measure the
16
+ PANEL rather than the viewport, so a narrow column or a resized window
17
+ narrows the board at the same threshold the shell uses for its columns. */
16
18
  [data-dsh-taskboard-view] {
17
- position: absolute;
18
- inset: 0;
19
- display: none;
20
- z-index: 60;
19
+ display: flex;
20
+ flex-direction: column;
21
+ box-sizing: border-box;
22
+ width: 100%;
23
+ height: 100%;
24
+ min-width: 0;
25
+ min-height: 0;
21
26
  container-name: task-board-view;
22
27
  container-type: inline-size;
23
- /* Opaque backdrop: the conversation subtree stays mounted under the view.
24
- Even if the hide rule below were defeated by a stronger host inline
25
- style, this background keeps sticky code-block banners (host z-index 6)
26
- from showing through the active board (issue #100). */
27
28
  background: var(--dsw-alias-bg-base);
28
29
  }
29
30
 
30
- /* The center column is single-occupant; the :not() guards keep the two
31
- sibling panels (task board / ssh) from fighting over visibility if both
32
- activation attributes ever coexist. */
33
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-dsh-taskboard-view] {
34
- display: block;
35
- }
36
-
37
- /* While the board is active, the conversation content underneath is hidden
38
- (it stays mounted and stateful). The !important is required: the dsh shell
39
- (0.1.0-rc.6) wraps the conversation view in a node with an inline
40
- `display: contents`, and inline styles beat a plain stylesheet rule. Without
41
- it the composer (input card) stays visible at the bottom and paints over the
42
- board modals' footer, hiding the run/delete buttons (issue #76).
43
- The rule targets both the pane attribute and the center-column class so the
44
- chat subtree (including the code-block copy banner, issue #100) stays hidden
45
- whichever host container carries it. */
46
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-taskboard-view]),
47
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol'] > :not([data-dsh-taskboard-view]) {
48
- display: none !important;
49
- }
50
-
51
- /* --- sidebar entry row ------------------------------------------------------- */
52
-
53
- /* Same geometry as the shell's own panel rows (SidebarRoot .panelRow): 2px
54
- inset with a 12px radius, 8px content padding, 36px row, 14/22 type, and the
55
- primary ink — so an injected row and an official row line up column for
56
- column instead of each carrying its own box. */
57
- .entry {
58
- box-sizing: border-box;
59
- display: flex;
60
- align-items: center;
61
- gap: 8px;
62
- min-height: 36px;
63
- padding: 7px 8px;
64
- margin: 0 2px;
65
- background: transparent;
66
- border: none;
67
- border-radius: 12px;
68
- color: var(--dsw-alias-label-primary);
69
- cursor: pointer;
70
- font: inherit;
71
- font-size: 14px;
72
- line-height: 22px;
73
- text-align: left;
74
- white-space: nowrap;
75
- }
76
-
77
- .entry:hover {
78
- background: var(--dsw-alias-interactive-bg-hover);
79
- color: var(--dsw-alias-label-primary);
80
- }
81
-
82
- .entry[data-active] {
83
- background: var(--dsw-alias-interactive-bg-active);
84
- color: var(--dsw-alias-label-primary);
85
- font-weight: 600;
86
- }
87
-
88
- /* Icon box matches the shell's panel glyph exactly (16px), so the glyph column
89
- and the label column land on the official rows' x positions. */
90
- .entryIcon {
91
- display: inline-flex;
92
- align-items: center;
93
- justify-content: center;
94
- width: 16px;
95
- height: 16px;
96
- flex: none;
97
- }
98
-
99
- .entryIcon svg {
100
- display: block;
101
- width: 16px;
102
- height: 16px;
103
- }
104
-
105
- .entryLabel {
106
- overflow: hidden;
107
- text-overflow: ellipsis;
108
- }
109
-
110
- /* Collapsed rail: icon-only, centered, matching the shell's 56px rail. The
111
- shell asks its own panel rows for an 18px glyph on the rail, so the icon box
112
- and the svg step up together with the 12px rail radius. */
113
- :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
114
- :global([data-sidebar-collapsed]) .entry {
115
- justify-content: center;
116
- padding: 0;
117
- width: 36px;
118
- height: 36px;
119
- margin: 0 auto 12px;
120
- border-radius: 12px;
121
- }
122
-
123
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon,
124
- :global([data-sidebar-collapsed]) .entryIcon,
125
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon svg,
126
- :global([data-sidebar-collapsed]) .entryIcon svg {
127
- width: 18px;
128
- height: 18px;
129
- }
130
-
131
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
132
- :global([data-sidebar-collapsed]) .entryLabel {
133
- display: none;
134
- }
135
-
136
31
  /* --- board frame -------------------------------------------------------------- */
137
32
 
138
33
  .board {
@@ -924,7 +819,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
924
819
  height: 100dvh;
925
820
  }
926
821
 
927
- .entry,
928
822
  .card,
929
823
  .primaryButton,
930
824
  .ghostButton,
@@ -1038,7 +932,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1038
932
  * --------------------------------------------------------------------------- */
1039
933
 
1040
934
  /* --- one themed focus-visible ring (approx 2px, 2px offset) --- */
1041
- .entry:focus-visible,
1042
935
  .card:focus-visible,
1043
936
  .primaryButton:focus-visible,
1044
937
  .ghostButton:focus-visible,
@@ -1055,7 +948,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1055
948
  }
1056
949
 
1057
950
  /* --- unified 120ms motion across the board's interactive controls --- */
1058
- .entry,
1059
951
  .primaryButton,
1060
952
  .ghostButton,
1061
953
  .dangerButton,
@@ -1075,7 +967,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1075
967
  transform: translateY(0);
1076
968
  }
1077
969
 
1078
- .entry:active,
1079
970
  .primaryButton:active:not(:disabled),
1080
971
  .ghostButton:active:not(:disabled),
1081
972
  .dangerButton:active:not(:disabled),
@@ -1084,11 +975,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1084
975
  transform: translateY(1px);
1085
976
  }
1086
977
 
1087
- /* --- entry row (sidebar) states --- */
1088
- .entry[data-active]:hover {
1089
- background: var(--dsw-specific-sidebar-nav-item-active);
1090
- }
1091
-
1092
978
  /* --- guarded hover for the remaining icon/link buttons (defensive: never
1093
979
  disabled today, but a disabled element must not react to hover) --- */
1094
980
  .iconButton:hover:not(:disabled) {
@@ -1121,7 +1007,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1121
1007
 
1122
1008
  /* --- reduced motion: strip transitions, stop the decorative spinner --- */
1123
1009
  @media (prefers-reduced-motion: reduce) {
1124
- .entry,
1125
1010
  .card,
1126
1011
  .primaryButton,
1127
1012
  .ghostButton,
@@ -1320,3 +1205,99 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1320
1205
  .aiParseRow .primaryButton {
1321
1206
  flex: none;
1322
1207
  }
1208
+
1209
+ /* --- subtask tree (parent link, cascade runs) ---------------------------------
1210
+ * A subtask is an ordinary card with a parent link; the badges below only mark
1211
+ * the lineage on the board, and the detail block lists the direct children
1212
+ * with their statuses.
1213
+ * ----------------------------------------------------------------------------- */
1214
+
1215
+ .cardSubtask {
1216
+ max-width: 100%;
1217
+ padding: 0 7px;
1218
+ overflow: hidden;
1219
+ border: 1px solid var(--dsw-alias-border-l2);
1220
+ border-radius: 999px;
1221
+ background: transparent;
1222
+ color: var(--dsw-alias-label-secondary);
1223
+ font-size: 10px;
1224
+ line-height: 16px;
1225
+ text-overflow: ellipsis;
1226
+ white-space: nowrap;
1227
+ }
1228
+
1229
+ .cardSubtask[data-tone='failed'] {
1230
+ color: var(--dsw-alias-state-error-primary);
1231
+ border-color: var(--dsw-alias-state-error-primary);
1232
+ }
1233
+
1234
+ .cardSubtask[data-tone='running'] {
1235
+ color: var(--dsw-alias-state-warn-primary);
1236
+ border-color: var(--dsw-alias-state-warn-primary);
1237
+ }
1238
+
1239
+ .cardSubtask[data-tone='done'] {
1240
+ color: var(--dsw-alias-state-success-primary);
1241
+ border-color: var(--dsw-alias-state-success-primary);
1242
+ }
1243
+
1244
+ .subtaskList {
1245
+ list-style: none;
1246
+ margin: 0;
1247
+ padding: 0;
1248
+ display: flex;
1249
+ flex-direction: column;
1250
+ gap: 6px;
1251
+ }
1252
+
1253
+ .subtaskRow {
1254
+ display: flex;
1255
+ align-items: center;
1256
+ gap: 10px;
1257
+ padding: 6px 10px;
1258
+ border: 1px solid var(--dsw-alias-border-l1);
1259
+ border-radius: 8px;
1260
+ flex-wrap: wrap;
1261
+ }
1262
+
1263
+ .subtaskRow .linkButton:first-child {
1264
+ flex: 1 1 auto;
1265
+ min-width: 0;
1266
+ text-align: left;
1267
+ overflow-wrap: anywhere;
1268
+ }
1269
+
1270
+ .subtaskAddRow {
1271
+ display: flex;
1272
+ gap: 8px;
1273
+ flex-wrap: wrap;
1274
+ }
1275
+
1276
+ .pickList {
1277
+ list-style: none;
1278
+ margin: 0;
1279
+ padding: 0;
1280
+ display: flex;
1281
+ flex-direction: column;
1282
+ gap: 6px;
1283
+ max-height: 320px;
1284
+ overflow-y: auto;
1285
+ }
1286
+
1287
+ .pickRow {
1288
+ display: flex;
1289
+ align-items: center;
1290
+ gap: 10px;
1291
+ padding: 6px 10px;
1292
+ border: 1px solid var(--dsw-alias-border-l1);
1293
+ border-radius: 8px;
1294
+ }
1295
+
1296
+ .pickTitle {
1297
+ flex: 1 1 auto;
1298
+ min-width: 0;
1299
+ font-size: 13px;
1300
+ color: var(--dsw-alias-label-primary);
1301
+ overflow-wrap: anywhere;
1302
+ }
1303
+
@@ -11,6 +11,16 @@ import {
11
11
  type TaskBoardSnapshot,
12
12
  } from '../protocol.ts'
13
13
 
14
+ /**
15
+ * Client-side base for the board's routes: DOCUMENT-RELATIVE (issue #1707).
16
+ *
17
+ * The host registers `TASK_BOARD_API_PREFIX` root-absolute; the page is served
18
+ * with `<base href="./">`, so the browser half must drop the leading slash or
19
+ * a sub-path deployment escapes its entry directory. Derived from the shared
20
+ * constant so host and client cannot drift.
21
+ */
22
+ const CLIENT_API_PREFIX = TASK_BOARD_API_PREFIX.slice(1)
23
+
14
24
  const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
15
25
  const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
16
26
  const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
@@ -114,7 +124,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
114
124
  }
115
125
 
116
126
  async state(): Promise<TaskBoardSnapshot> {
117
- return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
127
+ return await this.request(`${CLIENT_API_PREFIX}/state`, { cache: 'no-store' })
118
128
  }
119
129
 
120
130
  async action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
@@ -123,7 +133,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
123
133
 
124
134
  private async post(requestId: string, action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
125
135
  const envelope: TaskBoardActionEnvelope = { requestId, action, ...(initiator === undefined || initiator === '' ? {} : { initiator }) }
126
- return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
136
+ return await this.request(`${CLIENT_API_PREFIX}/action`, {
127
137
  method: 'POST',
128
138
  headers: { 'content-type': 'application/json' },
129
139
  body: JSON.stringify(envelope),
@@ -154,7 +164,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
154
164
  async parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
155
165
  let response: Response
156
166
  try {
157
- response = await fetch(`${TASK_BOARD_API_PREFIX}/parse`, {
167
+ response = await fetch(`${CLIENT_API_PREFIX}/parse`, {
158
168
  method: 'POST',
159
169
  headers: { 'content-type': 'application/json' },
160
170
  body: JSON.stringify(request),
@@ -193,7 +203,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
193
203
  }
194
204
 
195
205
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
196
- const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
206
+ const events = new EventSource(`${CLIENT_API_PREFIX}/events`)
197
207
  let lastStreamErrorNotify = 0
198
208
  events.onmessage = (message: MessageEvent<string>): void => {
199
209
  try {