@linxin666/dsh-client-ui-task-board 0.4.2 → 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 (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  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/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -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 {
@@ -520,10 +415,10 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
520
415
  .modal {
521
416
  display: flex;
522
417
  flex-direction: column;
523
- gap: 12px;
418
+ gap: 10px;
524
419
  width: min(520px, calc(100vw - 48px));
525
420
  max-height: calc(100vh - 96px);
526
- overflow-y: auto;
421
+ overflow: hidden;
527
422
  padding: 18px;
528
423
  background: var(--dsw-alias-bg-base);
529
424
  border: 1px solid var(--dsw-alias-border-l2);
@@ -554,6 +449,95 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
554
449
  margin-top: 4px;
555
450
  }
556
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
+
557
541
  .field {
558
542
  display: flex;
559
543
  flex-direction: column;
@@ -740,6 +724,21 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
740
724
  outline: none;
741
725
  }
742
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
+
743
742
  .scheduleMeta {
744
743
  margin: 0;
745
744
  font-size: 12px;
@@ -924,7 +923,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
924
923
  height: 100dvh;
925
924
  }
926
925
 
927
- .entry,
928
926
  .card,
929
927
  .primaryButton,
930
928
  .ghostButton,
@@ -1038,7 +1036,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1038
1036
  * --------------------------------------------------------------------------- */
1039
1037
 
1040
1038
  /* --- one themed focus-visible ring (approx 2px, 2px offset) --- */
1041
- .entry:focus-visible,
1042
1039
  .card:focus-visible,
1043
1040
  .primaryButton:focus-visible,
1044
1041
  .ghostButton:focus-visible,
@@ -1049,13 +1046,13 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1049
1046
  .input:focus-visible,
1050
1047
  .select:focus-visible,
1051
1048
  .schedulePreset:focus-visible,
1052
- .scheduleToggle input:focus-visible {
1049
+ .scheduleToggle input:focus-visible,
1050
+ .formSectionHeader:focus-visible {
1053
1051
  outline: 2px solid var(--dsw-alias-state-business-primary);
1054
1052
  outline-offset: 2px;
1055
1053
  }
1056
1054
 
1057
1055
  /* --- unified 120ms motion across the board's interactive controls --- */
1058
- .entry,
1059
1056
  .primaryButton,
1060
1057
  .ghostButton,
1061
1058
  .dangerButton,
@@ -1065,7 +1062,9 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1065
1062
  .input,
1066
1063
  .select,
1067
1064
  .schedulePreset,
1068
- .scheduleToggle input {
1065
+ .scheduleToggle input,
1066
+ .formSectionHeader,
1067
+ .formSectionChevron {
1069
1068
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, outline-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
1070
1069
  }
1071
1070
 
@@ -1075,7 +1074,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1075
1074
  transform: translateY(0);
1076
1075
  }
1077
1076
 
1078
- .entry:active,
1079
1077
  .primaryButton:active:not(:disabled),
1080
1078
  .ghostButton:active:not(:disabled),
1081
1079
  .dangerButton:active:not(:disabled),
@@ -1084,11 +1082,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1084
1082
  transform: translateY(1px);
1085
1083
  }
1086
1084
 
1087
- /* --- entry row (sidebar) states --- */
1088
- .entry[data-active]:hover {
1089
- background: var(--dsw-specific-sidebar-nav-item-active);
1090
- }
1091
-
1092
1085
  /* --- guarded hover for the remaining icon/link buttons (defensive: never
1093
1086
  disabled today, but a disabled element must not react to hover) --- */
1094
1087
  .iconButton:hover:not(:disabled) {
@@ -1121,7 +1114,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1121
1114
 
1122
1115
  /* --- reduced motion: strip transitions, stop the decorative spinner --- */
1123
1116
  @media (prefers-reduced-motion: reduce) {
1124
- .entry,
1125
1117
  .card,
1126
1118
  .primaryButton,
1127
1119
  .ghostButton,
@@ -1132,7 +1124,9 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1132
1124
  .input,
1133
1125
  .select,
1134
1126
  .schedulePreset,
1135
- .scheduleToggle input {
1127
+ .scheduleToggle input,
1128
+ .formSectionHeader,
1129
+ .formSectionChevron {
1136
1130
  transition: none;
1137
1131
  }
1138
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)
@@ -24,17 +24,17 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
24
24
  // Type-only: pulls the workspace plugin's Context merge (ctx.uiWorkspace), the
25
25
  // multi-instance navigation face that replaced ISessions.open().
26
26
  import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
27
- import { BoardController } from '../core/controller.ts'
27
+ import { BoardController, TASK_BOARD_PANEL_ID } from '../core/controller.ts'
28
28
  import { mainViewSessionId } from './main-session.ts'
29
29
  import { LocalStorageTaskStore } from '../core/store.ts'
30
30
  import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
31
- import { mountBoard } from './board-mount.tsx'
32
- import { mountSidebarEntry } from './sidebar-entry.ts'
31
+ import { registerTaskBoardPanel } from './native-panel.tsx'
33
32
  import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
34
33
  import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
35
34
  import { HttpTaskBoardHostTransport } from './host-api.ts'
36
35
  import { reportDailyHeartbeat } from './telemetry.ts'
37
36
  import { installPluginCard } from './plugin-card-seat.ts'
37
+ import { createServedEntryForm } from './settings-entry-form.ts'
38
38
 
39
39
  /** Locale namespace this plugin owns. */
40
40
  const NS = 'task-board'
@@ -124,7 +124,7 @@ declare module '@deepseek-ai/cordis' {
124
124
  * on hosts below that cohort, which serve the same roster through the
125
125
  * connection RPC face.
126
126
  */
127
- export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'configForms', 'locale', 'remote', 'remote.session', 'uiWorkspace']
127
+ export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'configForms', 'locale', 'remote', 'remote.session', 'uiWorkspace', 'layout']
128
128
 
129
129
  /** One agent-preset row the mode picker consumes (either face's wire shape). */
130
130
  interface PresetRosterRow {
@@ -241,6 +241,18 @@ export function apply(ctx: ClientContext): void {
241
241
  const controller = new BoardController({
242
242
  store,
243
243
  transport: new HttpTaskBoardHostTransport(),
244
+ // Panel navigation belongs to the layout service: the board asks it to
245
+ // select its own panel id (or the conversation) instead of taking over
246
+ // the center column at the DOM level. The lookup stays lazy so a
247
+ // selection issued before the service settles still lands once it does,
248
+ // and the face throws by contract before the layout root entry mounts,
249
+ // which the controller tolerates.
250
+ panel: {
251
+ select: panelId => {
252
+ const layout = ctx.get('layout') as { selectPanel?: (id: string | null) => void } | undefined
253
+ layout?.selectPanel?.(panelId)
254
+ },
255
+ },
244
256
  sessions: {
245
257
  // The main-view Session comes from the catalog's per-source ownership
246
258
  // counts; navigation belongs to the workspace UI since the
@@ -360,14 +372,30 @@ export function apply(ctx: ClientContext): void {
360
372
  console.error('[dsh-task-board] model options read failed', error)
361
373
  }
362
374
  }
375
+ void pushPresetOptions()
363
376
  void pushModelOptions()
364
- disposers.push(ctx.on('connection/reset', () => { void pushModelOptions() }))
377
+ disposers.push(ctx.on('connection/reset', () => {
378
+ void pushPresetOptions()
379
+ void pushModelOptions()
380
+ }))
381
+ // Native panel surfaces: one sidebar row and one center-column page, both
382
+ // through the official slot seats the shell itself renders. The layout's
383
+ // panel selection is reconciled back into the controller so the board's own
384
+ // view state follows the user clicking another panel row.
365
385
  try {
366
- disposers.push(mountSidebarEntry(controller, ctx.locale))
367
- disposers.push(mountBoard(controller, ctx.locale))
386
+ disposers.push(registerTaskBoardPanel(ctx, controller))
387
+ const layoutFace = ctx.get('layout') as { panelInfo?: { subscribe(listener: () => void): () => void; getSnapshot(): { activePanelId: unknown } } } | undefined
388
+ if (layoutFace?.panelInfo !== undefined) {
389
+ const sync = (): void => {
390
+ const active = layoutFace.panelInfo!.getSnapshot().activePanelId
391
+ controller.syncPanelSelection(active === TASK_BOARD_PANEL_ID ? TASK_BOARD_PANEL_ID : null)
392
+ }
393
+ sync()
394
+ disposers.push(layoutFace.panelInfo.subscribe(sync))
395
+ }
368
396
  } catch (error) {
369
- // DOM failures degrade the board, never the GUI.
370
- console.error('[dsh-task-board] mount failed:', error)
397
+ // A shell that cannot serve the seats degrades the board, never the GUI.
398
+ console.error('[dsh-task-board] panel registration failed:', error)
371
399
  }
372
400
 
373
401
  uiDisposer = () => {
@@ -415,22 +443,26 @@ export function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettin
415
443
  if (binder !== undefined && typeof binder.bind === 'function') {
416
444
  return binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
417
445
  }
418
- return ctx.configForms.get<TaskBoardSettings>(servedEntryId(ctx.configForms))
446
+ return createServedEntryForm<TaskBoardSettings>({
447
+ forms: ctx.configForms,
448
+ entryIds: TASK_BOARD_ENTRY_IDS,
449
+ })
419
450
  }
420
451
 
421
452
  /**
422
- * The profile entry id this package's own row carries.
453
+ * The profile entry id this package's own row carries, for a page that serves
454
+ * no family binder.
423
455
  *
424
456
  * The shared describe mirror is the only local evidence of which row id this
425
- * profile actually serves, but it answers asynchronously: at plugin
426
- * activation it usually holds nothing yet. An unanswered mirror therefore
427
- * binds the aggregate row id rather than guessing among the candidates —
428
- * the form is bound once for the session, so a wrong guess would leave the
429
- * card reporting an unserved namespace even after the mirror settles.
457
+ * profile actually serves, but it answers asynchronously: at plugin activation
458
+ * it usually holds nothing yet. An unanswered mirror therefore binds the
459
+ * aggregate row id rather than guessing among the candidates, and the binding
460
+ * is re-resolved once the mirror answers — see
461
+ * {@link createServedEntryForm}, which owns that rebinding.
430
462
  * @param forms - the shared configuration forms service.
431
- * @returns the entry id to bind.
463
+ * @returns the entry id to bind before the mirror answers.
432
464
  */
433
- function servedEntryId(forms: ConfigForms): string {
465
+ export function servedEntryId(forms: ConfigForms): string {
434
466
  let served: readonly string[] | undefined
435
467
  try {
436
468
  served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)