@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.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- 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
|
-
/* ---
|
|
7
|
+
/* --- native panel page ------------------------------------------------------- */
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
418
|
+
gap: 10px;
|
|
524
419
|
width: min(520px, calc(100vw - 48px));
|
|
525
420
|
max-height: calc(100vh - 96px);
|
|
526
|
-
overflow
|
|
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
|
|
package/src/client/host-api.ts
CHANGED
|
@@ -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 =
|
|
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('
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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)
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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', () => {
|
|
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(
|
|
367
|
-
|
|
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
|
-
//
|
|
370
|
-
console.error('[dsh-task-board]
|
|
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
|
|
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
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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)
|