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

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 (53) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/lib/client.js +1350 -552
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +655 -134
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/locales.d.ts +38 -2
  11. package/lib/types/client/schedule-zone.d.ts +47 -0
  12. package/lib/types/core/controller.d.ts +1 -0
  13. package/lib/types/core/degraded-reason.d.ts +46 -0
  14. package/lib/types/core/package-name.d.ts +9 -0
  15. package/lib/types/core/schedule.d.ts +95 -20
  16. package/lib/types/core/subtask.d.ts +30 -11
  17. package/lib/types/core/tasks.d.ts +26 -2
  18. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  19. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  20. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  21. package/lib/types/host-ledger.d.ts +56 -4
  22. package/lib/types/host-runner.d.ts +76 -3
  23. package/lib/types/host-service.d.ts +26 -0
  24. package/lib/types/protocol.d.ts +9 -3
  25. package/package.json +25 -25
  26. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  27. package/src/client/board/NewTaskModal.tsx +271 -150
  28. package/src/client/board/TaskCard.tsx +24 -5
  29. package/src/client/board/TaskDetail.tsx +83 -33
  30. package/src/client/board/TaskForm.tsx +48 -3
  31. package/src/client/board/preset-label.ts +44 -0
  32. package/src/client/board.module.css +114 -5
  33. package/src/client/host-api.ts +109 -6
  34. package/src/client/index.ts +5 -1
  35. package/src/client/locales.ts +82 -8
  36. package/src/client/schedule-zone.ts +135 -0
  37. package/src/core/controller.ts +7 -2
  38. package/src/core/degraded-reason.ts +81 -0
  39. package/src/core/package-name.ts +9 -0
  40. package/src/core/schedule.ts +211 -55
  41. package/src/core/store.ts +10 -1
  42. package/src/core/subtask.ts +77 -22
  43. package/src/core/tasks.ts +38 -4
  44. package/src/core/use-cases/task-create.ts +14 -2
  45. package/src/core/use-cases/task-schedule.ts +17 -6
  46. package/src/core/use-cases/task-update.ts +4 -1
  47. package/src/host/agent-tools.ts +40 -8
  48. package/src/host-ledger.ts +203 -23
  49. package/src/host-runner.ts +164 -14
  50. package/src/host-service.ts +94 -17
  51. package/src/protocol.ts +27 -8
  52. package/lib/types/host/run-guarded.d.ts +0 -39
  53. package/src/host/run-guarded.ts +0 -76
@@ -5,7 +5,7 @@
5
5
  * execution's session transcript.
6
6
  */
7
7
  import { useEffect, useState } from 'react'
8
- import type { BoardController } from '../../core/controller.ts'
8
+ import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
9
9
  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'
@@ -13,11 +13,13 @@ import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
13
  import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
14
14
  import { t, type TaskBoardKey } from '../locales.ts'
15
15
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
16
+ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
16
17
  import css from '../board.module.css'
17
18
  import { ConfirmDialog } from './ConfirmDialog.tsx'
18
19
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
19
20
  import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
20
21
  import { NewTaskModal } from './NewTaskModal.tsx'
22
+ import { inheritPresetLabel, presetLabel } from './preset-label.ts'
21
23
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
22
24
  import { STATUS_KEY } from './status-key.ts'
23
25
 
@@ -63,20 +65,16 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
63
65
  }
64
66
 
65
67
  /** The execution-target editor: workspace / mode / permission pickers. */
66
- function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
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())
72
- useEffect(
73
- () => controller.subscribe(() => {
74
- const snapshot = controller.getSnapshot()
75
- setOptions(snapshot.executionOptions)
76
- setTeamRunAvailable(snapshot.host?.teamRunAvailable === true)
77
- }),
78
- [controller],
79
- )
68
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
69
+ controller: BoardController
70
+ task: TaskRecord
71
+ pending: boolean
72
+ /** Picker option sets, owned by the detail overlay's snapshot. */
73
+ executionOptions: ControllerSnapshot['executionOptions']
74
+ /** Whether this deployment serves the Agent Teams service. */
75
+ teamRunAvailable: boolean
76
+ }) {
77
+ const options = executionOptions
80
78
  const workspaceId = task.workspaceId ?? ''
81
79
  const mode = task.mode ?? ''
82
80
  const permission = task.permission ?? ''
@@ -107,18 +105,18 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
107
105
  </select>
108
106
  </label>
109
107
  <label className={css.field}>
110
- <span className={css.fieldLabel}>{t('new.mode')}</span>
108
+ <span className={css.fieldLabel}>{t('new.agentPreset')}</span>
111
109
  <select
112
110
  className={css.select}
113
111
  value={mode}
114
112
  disabled={pending}
115
113
  onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
116
114
  >
117
- <option value="">{t('exec.mode.default')}</option>
115
+ <option value="">{inheritPresetLabel(options.presets)}</option>
118
116
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
119
117
  {options.presets.map(preset => (
120
118
  <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
121
- {preset.name ?? preset.id}
119
+ {presetLabel(preset)}
122
120
  {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
123
121
  {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
124
122
  </option>
@@ -164,6 +162,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
164
162
  <span>{t('exec.reuseSession')}</span>
165
163
  </label>
166
164
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
165
+ <label className={css.scheduleToggle}>
166
+ <input
167
+ type="checkbox"
168
+ checked={task.goalRun !== false}
169
+ disabled={pending}
170
+ onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
171
+ />
172
+ <span>{t('exec.goalRun')}</span>
173
+ </label>
174
+ <p className={css.detailText}>{t('exec.goalRunHint')}</p>
167
175
  <label className={css.scheduleToggle}>
168
176
  <input
169
177
  type="checkbox"
@@ -178,7 +186,7 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
178
186
  )
179
187
  }
180
188
 
181
- /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
189
+ /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
182
190
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
183
191
  const schedule = task.schedule
184
192
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
@@ -186,7 +194,12 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
186
194
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
187
195
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
188
196
  const [error, setError] = useState<string | undefined>(undefined)
189
- const timeZone = controller.getSnapshot().host?.scheduler.timeZone
197
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
198
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
199
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
200
+ // One clock reading per render pass, so the absolute and relative halves of
201
+ // the label always describe the same instant.
202
+ const now = Date.now()
190
203
 
191
204
  // Keep the editor in sync when the task record changes underneath (the
192
205
  // schedule rolls forward as runs trigger).
@@ -195,8 +208,9 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
195
208
  setEnabled(schedule?.enabled ?? false)
196
209
  setNextRunAt(schedule?.nextRunAt)
197
210
  setLastTriggeredAt(schedule?.lastTriggeredAt)
211
+ setZone(schedule?.timeZone ?? '')
198
212
  setError(undefined)
199
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.nextRunAt, schedule?.lastTriggeredAt])
213
+ }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
200
214
 
201
215
  /** Validate + persist the current cron text (Enter or blur). */
202
216
  const saveCron = (value: string): void => {
@@ -210,6 +224,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
210
224
  controller.setSchedule(task.id, { cron: trimmed })
211
225
  }
212
226
 
227
+ /**
228
+ * Change the rule's zone. `''` clears the stored zone, which is how a user
229
+ * returns a rule to following the Host zone.
230
+ */
231
+ const changeZone = (value: string): void => {
232
+ setZone(value)
233
+ setError(undefined)
234
+ controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
235
+ }
236
+
213
237
  /** Arm/disarm the schedule (arming first persists the edited cron). */
214
238
  const toggleEnabled = (next: boolean): void => {
215
239
  const trimmed = cron.trim()
@@ -232,12 +256,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
256
  controller.setSchedule(task.id, { cron: preset })
233
257
  }
234
258
 
259
+ // The zone the rule's wall clock is actually read in: its own when set,
260
+ // otherwise the Host zone. The preview must use the same one the Host will.
261
+ const effectiveZone = zone === '' ? hostTimeZone : zone
235
262
  const nextLabel = !enabled || nextRunAt === undefined
236
263
  ? t('detail.schedule.notScheduled')
237
- : nextRunAt <= Date.now()
264
+ : nextRunAt <= now
238
265
  ? t('detail.schedule.dueSoon')
239
- : formatHostTimestamp(nextRunAt, timeZone)
240
- const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
266
+ : nextRunLabel(nextRunAt, effectiveZone, formatHostTimestamp, now)
267
+ const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, effectiveZone)
268
+ // The rule's own stored zone rides the list even when this runtime's
269
+ // inventory does not carry it, so opening the editor cannot rewrite it.
270
+ const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
241
271
 
242
272
  return (
243
273
  <section className={css.detailSection}>
@@ -276,6 +306,23 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
276
306
  ))}
277
307
  </select>
278
308
  </div>
309
+ {/* A list is used instead of a free-text field so only zones the Host can
310
+ also resolve are offered; the Host zone entry clears the stored value. */}
311
+ <label className={css.scheduleZone}>
312
+ <span>{t('detail.schedule.timeZone')}</span>
313
+ <select
314
+ className={css.schedulePreset}
315
+ value={zone}
316
+ disabled={pending}
317
+ aria-label={t('detail.schedule.timeZone')}
318
+ title={t('detail.schedule.timeZoneHint')}
319
+ onChange={event => { changeZone(event.target.value) }}
320
+ >
321
+ {zones.map(choice => (
322
+ <option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
323
+ ))}
324
+ </select>
325
+ </label>
279
326
  {error !== undefined && <p className={css.formError}>{error}</p>}
280
327
  <p className={css.scheduleMeta}>
281
328
  {t('detail.schedule.nextRun')} {nextLabel}
@@ -290,17 +337,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
290
337
  * and the actions that grow or prune the tree. Every gate here mirrors the
291
338
  * Host lineage gate for affordance only; the Host re-checks the action.
292
339
  */
293
- function SubtaskSection({ controller, task, pending, archived }: {
340
+ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
294
341
  controller: BoardController
295
342
  task: TaskRecord
296
343
  pending: boolean
297
344
  archived: boolean
345
+ /** Detail-overlay snapshot; the overlay already re-renders on every notify. */
346
+ snapshot: ControllerSnapshot
298
347
  }) {
299
- const [snapshot, setSnapshot] = useState(controller.getSnapshot())
300
- useEffect(
301
- () => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
302
- [controller],
303
- )
304
348
  const [showAdd, setShowAdd] = useState(false)
305
349
  const [showLink, setShowLink] = useState(false)
306
350
  const tasks = snapshot.tasks
@@ -440,7 +484,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
440
484
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
441
485
  </section>
442
486
 
443
- <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} />
487
+ <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
444
488
 
445
489
  {current.tags !== undefined && current.tags.length > 0 && (
446
490
  <section className={css.detailSection} data-dsh-part="tags">
@@ -484,7 +528,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
484
528
  <h4>{t('detail.handover')}</h4>
485
529
  <p className={css.detailText}>
486
530
  {t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
487
- {' · '}{t('new.mode')}: {current.handover.mode ?? t('exec.mode.default')}
531
+ {' · '}{t('new.agentPreset')}: {current.handover.mode ?? t('exec.mode.inherit')}
488
532
  {' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
489
533
  </p>
490
534
  <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
@@ -518,7 +562,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
518
562
 
519
563
  {!archived && (
520
564
  <>
521
- <ExecutionSettingsSection controller={controller} task={current} pending={pending} />
565
+ <ExecutionSettingsSection
566
+ controller={controller}
567
+ task={current}
568
+ pending={pending}
569
+ executionOptions={snapshot.executionOptions}
570
+ teamRunAvailable={snapshot.host?.teamRunAvailable === true}
571
+ />
522
572
  <ScheduleSection controller={controller} task={current} pending={pending} />
523
573
  </>
524
574
  )}
@@ -4,7 +4,7 @@
4
4
  * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
5
  * are controlled components.
6
6
  */
7
- import type { ReactNode } from 'react'
7
+ 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'
@@ -45,7 +45,7 @@ export function ModalShell({
45
45
  >
46
46
  <h2 className={css.modalTitle}>{title}</h2>
47
47
 
48
- {children}
48
+ <div className={css.modalBody}>{children}</div>
49
49
 
50
50
  {error !== undefined && <p className={css.formError}>{error}</p>}
51
51
 
@@ -72,6 +72,51 @@ export function ModalShell({
72
72
  )
73
73
  }
74
74
 
75
+ /**
76
+ * One collapsible region of a task form (create/edit dialog UX): the form
77
+ * groups its configuration into a few regions instead of stacking every field
78
+ * in one long column, so a normal-sized dialog needs no scrollbar. A collapsed
79
+ * region keeps a one-line summary of the values it holds, so nothing is hidden
80
+ * without a trace; a region holding a blocking error is forced open by its
81
+ * owner (forceOpen) rather than reporting the error out of sight.
82
+ */
83
+ export function CollapsibleSection({
84
+ title,
85
+ summary,
86
+ defaultOpen = false,
87
+ forceOpen = false,
88
+ children,
89
+ }: {
90
+ title: string
91
+ /** One-line value preview rendered only while the region is collapsed. */
92
+ summary?: string | undefined
93
+ /** Whether the region starts expanded (the owner's default layout). */
94
+ defaultOpen?: boolean
95
+ /** Open regardless of the stored state: an error inside must stay visible. */
96
+ forceOpen?: boolean
97
+ children: ReactNode
98
+ }) {
99
+ const [open, setOpen] = useState(defaultOpen)
100
+ const expanded = forceOpen || open
101
+ return (
102
+ <section className={css.formSection} data-dsh-part="form-section" data-open={expanded ? 'true' : undefined}>
103
+ <button
104
+ type="button"
105
+ className={css.formSectionHeader}
106
+ aria-expanded={expanded}
107
+ onClick={() => { setOpen(value => !value) }}
108
+ >
109
+ <span className={css.formSectionChevron} aria-hidden="true" />
110
+ <span className={css.formSectionTitle}>{title}</span>
111
+ {!expanded && summary !== undefined && summary !== '' && (
112
+ <span className={css.formSectionSummary}>{summary}</span>
113
+ )}
114
+ </button>
115
+ {expanded && <div className={css.formSectionBody}>{children}</div>}
116
+ </section>
117
+ )
118
+ }
119
+
75
120
  /** Title + description + prompt fields shared by the new and edit task forms. */
76
121
  export function TaskContentFields({
77
122
  title,
@@ -106,7 +151,7 @@ export function TaskContentFields({
106
151
  <span className={css.fieldLabel}>{t('new.description')}</span>
107
152
  <textarea
108
153
  className={css.input}
109
- rows={3}
154
+ rows={2}
110
155
  value={description}
111
156
  placeholder={t('new.descriptionPlaceholder')}
112
157
  onChange={event => onDescriptionChange(event.target.value)}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Display-only labels for the agent-preset roster, shared by the task form and
3
+ * the detail view.
4
+ *
5
+ * The deployment's four built-in presets (the `standard` / `ptc` / `minimal` /
6
+ * `cordis` rows the shipped web-app bundle declares) carry no display name in
7
+ * the registry roster, so a picker would list bare ids beside the user presets.
8
+ * The map below only supplies names for those four; any other id — a renamed
9
+ * built-in, a community preset, a user preset — falls back to the roster name
10
+ * and then to the id, so this table can never hide or mislabel a preset.
11
+ */
12
+ import type { ExecutionPresetOption } from '../../core/controller.ts'
13
+ import { t, type TaskBoardKey } from '../locales.ts'
14
+
15
+ /** Built-in preset id -> display-name key (display sugar only). */
16
+ const BUILTIN_PRESET_LABELS: Record<string, TaskBoardKey> = {
17
+ standard: 'preset.builtin.standard',
18
+ ptc: 'preset.builtin.ptc',
19
+ minimal: 'preset.builtin.minimal',
20
+ cordis: 'preset.builtin.cordis',
21
+ }
22
+
23
+ /** Whether one roster row is a shipped built-in preset. */
24
+ export function isBuiltinPreset(id: string): boolean {
25
+ return BUILTIN_PRESET_LABELS[id] !== undefined
26
+ }
27
+
28
+ /** Display label of one preset row: built-in label, roster name, or raw id. */
29
+ export function presetLabel(preset: Pick<ExecutionPresetOption, 'id' | 'name'>): string {
30
+ const builtin = BUILTIN_PRESET_LABELS[preset.id]
31
+ if (builtin !== undefined && (preset.name ?? '').trim() === '') return t(builtin)
32
+ return preset.name ?? preset.id
33
+ }
34
+
35
+ /**
36
+ * Label of the empty ("inherit") choice: names the preset a run actually lands
37
+ * on when the card pins nothing, which is the deployment default the roster
38
+ * marks with `isDefault`.
39
+ */
40
+ export function inheritPresetLabel(presets: readonly ExecutionPresetOption[]): string {
41
+ const fallback = presets.find(preset => preset.isDefault)
42
+ if (fallback === undefined) return t('exec.mode.inherit')
43
+ return t('exec.mode.inheritWith', { preset: presetLabel(fallback) })
44
+ }
@@ -415,10 +415,10 @@
415
415
  .modal {
416
416
  display: flex;
417
417
  flex-direction: column;
418
- gap: 12px;
418
+ gap: 10px;
419
419
  width: min(520px, calc(100vw - 48px));
420
420
  max-height: calc(100vh - 96px);
421
- overflow-y: auto;
421
+ overflow: hidden;
422
422
  padding: 18px;
423
423
  background: var(--dsw-alias-bg-base);
424
424
  border: 1px solid var(--dsw-alias-border-l2);
@@ -449,6 +449,95 @@
449
449
  margin-top: 4px;
450
450
  }
451
451
 
452
+ /* --- form sections (create/edit dialog) --------------------------------------
453
+ * The task form groups its configuration into collapsible regions so the
454
+ * dialog fits without a scrollbar: only the content region starts open, and
455
+ * every collapsed header carries a one-line summary of what it holds. The
456
+ * body keeps an overflow fallback for tiny viewports. The chevron is drawn
457
+ * with borders (no glyph), so no font or emoji can change it.
458
+ * ----------------------------------------------------------------------------- */
459
+
460
+ .modalBody {
461
+ display: flex;
462
+ flex-direction: column;
463
+ gap: 8px;
464
+ min-height: 0;
465
+ overflow-y: auto;
466
+ }
467
+
468
+ /* Every direct child keeps its natural height: a column flex item shrinks by
469
+ default, and a shrunk region would clip its own fields behind its
470
+ `overflow: hidden` instead of letting the body scroll. */
471
+ .modalBody > * {
472
+ flex: none;
473
+ }
474
+
475
+ .formSection {
476
+ display: flex;
477
+ flex-direction: column;
478
+ border: 1px solid var(--dsw-alias-separator-primary);
479
+ border-radius: 10px;
480
+ overflow: hidden;
481
+ }
482
+
483
+ .formSectionHeader {
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 8px;
487
+ width: 100%;
488
+ padding: 7px 10px;
489
+ font-family: inherit;
490
+ font-size: 12.5px;
491
+ font-weight: 600;
492
+ color: var(--dsw-alias-label-primary);
493
+ text-align: left;
494
+ background: transparent;
495
+ border: 0;
496
+ cursor: pointer;
497
+ }
498
+
499
+ .formSectionHeader:hover {
500
+ background: var(--dsw-alias-bg-mask-1);
501
+ }
502
+
503
+ .formSectionChevron {
504
+ flex: none;
505
+ width: 0;
506
+ height: 0;
507
+ margin-left: 1px;
508
+ border-top: 4px solid transparent;
509
+ border-bottom: 4px solid transparent;
510
+ border-left: 5px solid var(--dsw-alias-label-tertiary);
511
+ transition: transform 120ms ease;
512
+ }
513
+
514
+ .formSection[data-open='true'] .formSectionChevron {
515
+ transform: rotate(90deg);
516
+ }
517
+
518
+ .formSectionTitle {
519
+ flex: none;
520
+ }
521
+
522
+ .formSectionSummary {
523
+ flex: 1 1 auto;
524
+ min-width: 0;
525
+ overflow: hidden;
526
+ font-weight: 400;
527
+ color: var(--dsw-alias-label-tertiary);
528
+ text-align: right;
529
+ text-overflow: ellipsis;
530
+ white-space: nowrap;
531
+ }
532
+
533
+ .formSectionBody {
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 8px;
537
+ padding: 8px 10px 10px;
538
+ border-top: 1px solid var(--dsw-alias-separator-primary);
539
+ }
540
+
452
541
  .field {
453
542
  display: flex;
454
543
  flex-direction: column;
@@ -635,6 +724,21 @@
635
724
  outline: none;
636
725
  }
637
726
 
727
+ /* Zone row: the label and the picker share the cron row's geometry so the two
728
+ controls read as one editor. */
729
+ .scheduleZone {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 8px;
733
+ font-size: 12.5px;
734
+ color: var(--dsw-alias-label-secondary);
735
+ }
736
+
737
+ .scheduleZone select {
738
+ flex: 1;
739
+ min-width: 0;
740
+ }
741
+
638
742
  .scheduleMeta {
639
743
  margin: 0;
640
744
  font-size: 12px;
@@ -942,7 +1046,8 @@
942
1046
  .input:focus-visible,
943
1047
  .select:focus-visible,
944
1048
  .schedulePreset:focus-visible,
945
- .scheduleToggle input:focus-visible {
1049
+ .scheduleToggle input:focus-visible,
1050
+ .formSectionHeader:focus-visible {
946
1051
  outline: 2px solid var(--dsw-alias-state-business-primary);
947
1052
  outline-offset: 2px;
948
1053
  }
@@ -957,7 +1062,9 @@
957
1062
  .input,
958
1063
  .select,
959
1064
  .schedulePreset,
960
- .scheduleToggle input {
1065
+ .scheduleToggle input,
1066
+ .formSectionHeader,
1067
+ .formSectionChevron {
961
1068
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, outline-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
962
1069
  }
963
1070
 
@@ -1017,7 +1124,9 @@
1017
1124
  .input,
1018
1125
  .select,
1019
1126
  .schedulePreset,
1020
- .scheduleToggle input {
1127
+ .scheduleToggle input,
1128
+ .formSectionHeader,
1129
+ .formSectionChevron {
1021
1130
  transition: none;
1022
1131
  }
1023
1132
 
@@ -1,3 +1,5 @@
1
+ import { degradedRowOf, ledgerOwnerPid } from '../core/degraded-reason.ts'
2
+ import { TASK_BOARD_PACKAGE } from '../core/package-name.ts'
1
3
  import type { TaskRecord } from '../core/tasks.ts'
2
4
  import { t } from './locales.ts'
3
5
  import {
@@ -37,7 +39,20 @@ const TASK_PARSE_TIMEOUT_SECONDS = 45
37
39
  * "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
38
40
  * Host half looks like today.
39
41
  */
40
- export type HostApiFailure = 'not-mounted' | 'unauthorized' | 'locked' | 'rejected' | 'timeout' | 'unreachable' | 'unexpected'
42
+ export type HostApiFailure =
43
+ /** The board's own routes are not registered and nothing explains why. */
44
+ | 'not-mounted'
45
+ /** Another live DSH process owns the ledger, so this instance registered no board routes (#1730). */
46
+ | 'ledger-locked'
47
+ /** This row degraded during start for some other reason the family health route reports (#1730). */
48
+ | 'degraded'
49
+ | 'unauthorized'
50
+ | 'forbidden'
51
+ | 'locked'
52
+ | 'rejected'
53
+ | 'timeout'
54
+ | 'unreachable'
55
+ | 'unexpected'
41
56
 
42
57
  /** Transport failure carrying a stable class next to its user-facing message. */
43
58
  export class HostApiError extends Error {
@@ -47,6 +62,86 @@ export class HostApiError extends Error {
47
62
  }
48
63
  }
49
64
 
65
+ /**
66
+ * Same-origin family health route served by the dsh-web-all shell: the
67
+ * degraded ledger, in which a family row that failed during apply appears WITH
68
+ * its reason. DOCUMENT-RELATIVE for the same reason as the board's own routes
69
+ * (issue #1707). A 404 here means the family shell is absent or older than the
70
+ * route, which is not itself a failure of the board.
71
+ */
72
+ const FAMILY_DEGRADED_ROUTE = 'api/dsh-web-all/degraded'
73
+
74
+ /** Health-route ceiling: an unresponsive family shell must not hold the board's first paint. */
75
+ const DEGRADED_LOOKUP_TIMEOUT_MS = 2_000
76
+
77
+ /**
78
+ * The failure text for a row this browser knows failed during start.
79
+ *
80
+ * A ledger lock is the one class with an owner the user can act on, so its
81
+ * reason is unpacked into the pid plus what to do about it; every other
82
+ * degraded row is reported with the shell's own reason verbatim. The raw
83
+ * reason rides along either way, because it is the only text that names the
84
+ * lock file or the recovery step when the pid is not the whole story.
85
+ * @param reason - the shell's one-line failure reason (never empty).
86
+ */
87
+ function degradedFailureMessage(reason: string): { failure: 'ledger-locked' | 'degraded'; message: string } {
88
+ const pid = ledgerOwnerPid(reason)
89
+ if (pid === undefined) return { failure: 'degraded', message: t('board.hostError.degraded', { detail: reason }) }
90
+ return { failure: 'ledger-locked', message: t('board.hostError.ledgerLocked', { pid: String(pid), detail: reason }) }
91
+ }
92
+
93
+ /**
94
+ * The degraded record the family shell holds for THIS board, or undefined when
95
+ * the row is healthy, the shell is absent, or the answer is unusable. The
96
+ * lookup is best-effort by contract: any uncertainty keeps the caller's own
97
+ * failure text instead of replacing it with a weaker one.
98
+ * @param fetchImpl - fetch implementation (injected for tests).
99
+ * @returns the ledger's reason for this row, or undefined when it names none.
100
+ */
101
+ async function fetchDegradedReason(fetchImpl: typeof fetch): Promise<string | undefined> {
102
+ const controller = new AbortController()
103
+ const timer = globalThis.setTimeout(() => { controller.abort() }, DEGRADED_LOOKUP_TIMEOUT_MS)
104
+ try {
105
+ const response = await fetchImpl(FAMILY_DEGRADED_ROUTE, { signal: controller.signal, headers: { accept: 'application/json' }, cache: 'no-store' })
106
+ if (!response.ok) return undefined
107
+ return degradedRowOf(await response.json(), TASK_BOARD_PACKAGE)?.reason
108
+ } catch {
109
+ return undefined
110
+ } finally {
111
+ globalThis.clearTimeout(timer)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Name the real reason this board's routes are missing (issue #1730).
117
+ *
118
+ * The core webserver answers an unmounted route with a bare 404, which cannot
119
+ * distinguish "this deployment never loaded the plugin" from "this row
120
+ * degraded during start" — a second DSH process holding `ledger-v2.lock` is
121
+ * the case that matters, because the panel's advice ("restart the service")
122
+ * is then false: the lock belongs to the other process, and restarting cannot
123
+ * win it. The family health route knows the difference, so the 404 is
124
+ * disambiguated there before anything is shown.
125
+ * @param fallback - copy for the plain "routes are not there" failure, which
126
+ * is route-specific (the board's state route and the AI-parse route phrase
127
+ * their own absence); it is also what is shown when the ledger names nothing.
128
+ * @param fetchImpl - fetch implementation (injected for tests).
129
+ * @returns the HostApiError to throw for a missing board route.
130
+ */
131
+ export async function missingRouteFailure(
132
+ fallback: string = t('board.hostError.notMounted'),
133
+ fetchImpl: typeof fetch = fetch,
134
+ ): Promise<HostApiError> {
135
+ const absent = (): HostApiError => new HostApiError('not-mounted', fallback, 404)
136
+ const reason = await fetchDegradedReason(fetchImpl)
137
+ // A shell that predates the reason field (or recorded none) tells us the row
138
+ // is degraded but not why; the route's own wording is more honest than an
139
+ // empty "because".
140
+ if (reason === undefined || reason === '') return absent()
141
+ const described = degradedFailureMessage(reason)
142
+ return new HostApiError(described.failure, described.message, 404)
143
+ }
144
+
50
145
  function uuid(): string {
51
146
  return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
52
147
  }
@@ -79,14 +174,18 @@ async function readJson<T>(response: Response): Promise<T> {
79
174
  // The Host answered with a reason of its own. A ledger lock is the one
80
175
  // class worth naming in the panel's words; the authentication fence and
81
176
  // every other action rejection are reported as they arrive.
82
- if (hostError === 'forbidden') throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
177
+ if (hostError === 'forbidden') throw new HostApiError('forbidden', t('board.hostError.forbidden'), response.status)
83
178
  if (response.status === 503 || /lock/i.test(hostError)) {
84
179
  throw new HostApiError('locked', t('board.hostError.locked', { detail: hostError }), response.status)
85
180
  }
86
181
  throw new HostApiError('rejected', hostError, response.status)
87
182
  }
88
- if (response.status === 404) throw new HostApiError('not-mounted', t('board.hostError.notMounted'), 404)
89
- if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
183
+ // A bare 404 is the ONLY signal that this board's routes are absent; the
184
+ // family health route is what turns it into the real reason when this row
185
+ // degraded during start (issue #1730).
186
+ if (response.status === 404) throw await missingRouteFailure()
187
+ if (response.status === 403) throw new HostApiError('forbidden', t('board.hostError.forbidden'), 403)
188
+ if (response.status === 401) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), 401)
90
189
  throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
91
190
  }
92
191
 
@@ -193,8 +292,12 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
193
292
  if (record !== undefined && isTaskParseDraft(record.draft)) return record.draft
194
293
  throw new HostApiError('unexpected', t('new.aiParseFailed', { error: t('board.hostError.unexpected', { status: String(response.status) }) }), response.status)
195
294
  }
196
- if (response.status === 404) throw new HostApiError('not-mounted', t('new.aiParseUnavailable'), 404)
197
- if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
295
+ // The parse route lives beside the board's own routes, so a 404 means the
296
+ // same thing here: absent routes, with the same health-ledger detail and
297
+ // this route's own wording as the fallback.
298
+ if (response.status === 404) throw await missingRouteFailure(t('new.aiParseUnavailable'))
299
+ if (response.status === 403) throw new HostApiError('forbidden', t('board.hostError.forbidden'), 403)
300
+ if (response.status === 401) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), 401)
198
301
  const code = typeof record?.code === 'string' ? record.code : undefined
199
302
  if (code === 'no-model') throw new HostApiError('rejected', t('new.aiParseNoModel'), response.status)
200
303
  if (code === 'timeout') throw new HostApiError('timeout', t('new.aiParseTimeout', { seconds: String(TASK_PARSE_TIMEOUT_SECONDS) }), response.status)