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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,15 @@ export declare class HostTaskLedger {
116
116
  runtimeView(): LedgerRuntimeView;
117
117
  /** Count armed, non-archived schedules without cloning task histories. */
118
118
  armedScheduleCount(): number;
119
+ /**
120
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
121
+ * undefined when no schedule is armed in the future. This is the instant the
122
+ * Host arms its native timer at, so the board wakes exactly when work is due
123
+ * instead of polling a fixed heartbeat.
124
+ * @param now - current Host time in ms epoch.
125
+ * @returns the nearest future trigger, or undefined when none is armed.
126
+ */
127
+ nextArmedRunAt(now: number): number | undefined;
119
128
  /** Return value-only references for schedules due at the supplied Host time. */
120
129
  dueSchedules(now: number): DueScheduleReference[];
121
130
  subscribe(listener: () => void): () => void;
@@ -31,16 +31,30 @@ export interface TeamSpawnResult {
31
31
  export interface TaskBoardTeamDispatcher {
32
32
  spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>;
33
33
  }
34
+ /**
35
+ * The native timer face the Host arms through. The cordis `timer` service
36
+ * (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
37
+ * registered on the owning fiber, so unloading the board clears every armed
38
+ * timer without this service tracking handles by hand. A composition that
39
+ * serves no timer service falls back to the process globals.
40
+ */
41
+ export interface HostTimerFace {
42
+ timeout(callback: () => void, delay: number): () => void;
43
+ interval(callback: () => void, delay: number): () => void;
44
+ }
34
45
  export declare class TaskBoardHostService {
35
46
  readonly ledger: HostTaskLedger;
36
47
  readonly runner: HostExecutionRunner;
37
48
  readonly power: PowerInhibitor;
38
49
  private readonly listeners;
39
- private timers;
40
- private lastScheduleTick;
50
+ /** The one recurring timer: the session-roster poll. */
51
+ private pollTimer;
52
+ /** The armed schedule timer, if a trigger is pending. */
53
+ private scheduleTimer;
54
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
55
+ private scheduleTarget;
41
56
  private disposed;
42
57
  private pollInFlight;
43
- private tickInFlight;
44
58
  private active;
45
59
  /**
46
60
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -51,6 +65,7 @@ export declare class TaskBoardHostService {
51
65
  private idleSessionIds;
52
66
  private preventIdleSleep;
53
67
  private readonly team;
68
+ private readonly timers;
54
69
  private lastPowerJson;
55
70
  private readonly now;
56
71
  constructor(gateway: TypertGateway, options?: {
@@ -62,6 +77,7 @@ export declare class TaskBoardHostService {
62
77
  sessionDefaultPermission?: TaskPermission;
63
78
  maxSubtaskDepth?: number;
64
79
  team?: TaskBoardTeamDispatcher;
80
+ timers?: HostTimerFace;
65
81
  });
66
82
  start(): void;
67
83
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
@@ -84,7 +100,32 @@ export declare class TaskBoardHostService {
84
100
  private pollSessions;
85
101
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
86
102
  private reconcileExecutions;
87
- private tickSchedule;
103
+ /** Drop the armed schedule timer and forget its target. */
104
+ private clearScheduleTimer;
105
+ /**
106
+ * Boot / resume recovery: skip every occurrence that came due while the
107
+ * board was not running and roll each schedule to its next future target,
108
+ * then arm the timer for the nearest one. Rendering the occurrence is
109
+ * deliberately not attempted: the ACL of a card that fired hours ago is
110
+ * stale, and the board's own recovery contract is "missed triggers are
111
+ * skipped, never replayed".
112
+ */
113
+ private recoverSchedule;
114
+ /**
115
+ * Arm the native timer at the nearest armed future trigger. One timer serves
116
+ * every schedule: the ledger's next target is the only instant the Host has
117
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
118
+ * segments, and a target already past (the wall clock jumped, or the process
119
+ * was suspended) is handled immediately as a recovery.
120
+ */
121
+ private armSchedule;
122
+ /**
123
+ * One armed target became due. A fire landing well past its target is a
124
+ * resume (suspend, throttle, forward clock jump) rather than a normal
125
+ * occurrence, so it takes the recovery path instead of launching a run for a
126
+ * long-stale instant.
127
+ */
128
+ private onScheduleFire;
88
129
  private armedSchedules;
89
130
  /**
90
131
  * Launch one run set. The root goes first and receives the run shape in its
@@ -96,7 +137,12 @@ export declare class TaskBoardHostService {
96
137
  private dispatchRuns;
97
138
  private scheduleLaunch;
98
139
  private schedulePoll;
99
- private scheduleTick;
140
+ /**
141
+ * Re-arm from the ledger's current targets. Callers that just changed a
142
+ * schedule (the host routes, the agent tools) invoke this after the write
143
+ * commits, so a new or edited trigger arms without waiting for the next fire.
144
+ */
145
+ refreshSchedule(): void;
100
146
  private syncPowerReasons;
101
147
  private emit;
102
148
  }
@@ -9,7 +9,7 @@ import type { Context, Volatile } from '@deepseek-ai/cordis';
9
9
  import z from '@deepseek-ai/schemastery';
10
10
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
11
11
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
12
- import { type TaskBoardTeamDispatcher } from './host-service.ts';
12
+ import { type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts';
13
13
  import { type TaskPermission } from './core/tasks.ts';
14
14
  /** Default environment variable holding the authenticated proxy token. */
15
15
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
@@ -139,6 +139,16 @@ interface AgentToolRegistry {
139
139
  * @returns the registry, or undefined when this deployment serves none.
140
140
  */
141
141
  export declare function resolveToolRegistry(ctx: Context): AgentToolRegistry | undefined;
142
+ /**
143
+ * Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
144
+ * row). The board arms its schedule through it so unloading the row clears
145
+ * every pending timer with the owning fiber, instead of the row tracking raw
146
+ * process handles by hand. A composition that serves no timer service keeps
147
+ * working on the process globals.
148
+ * @param ctx - the plugin context.
149
+ * @returns the timer face, or undefined when this deployment serves none.
150
+ */
151
+ export declare function resolveHostTimers(ctx: Context): HostTimerFace | undefined;
142
152
  /** The subset of the Agent Teams service this plugin drives. */
143
153
  interface AgentTeamsFace {
144
154
  spawnTeammate(caller: unknown, request: {
@@ -4,19 +4,50 @@
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
7
- * and fail the boot. mountOnce makes the second host apply a no-op for the
8
- * lifetime of the first instance (the browser half is already deduped by
9
- * package name in the client module host).
7
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
8
+ * no-op for as long as the first instance lives (the browser half is already
9
+ * deduped by package name in the client module host).
10
+ *
11
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
12
+ * long after the refused mount ran its course: the Host reloads a profile by
13
+ * creating the new loader entries before the old ones are torn down (a
14
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
15
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
16
+ * previous entry still owns the name. Dropping that refused mount lost the
17
+ * plugin for good - the previous entry then disposed its own mount, releasing
18
+ * the name with nobody left to take it, and the family row stayed listed as
19
+ * active while its host routes 404ed (the task-board panel showed
20
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
21
+ * restart recovered it).
22
+ *
23
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
24
+ * the holder releases the name - if the waiting fiber is still alive then. The
25
+ * single-instance guarantee is unchanged: exactly one mount is live per
26
+ * package name, and the replay re-enters the guard so a later mount still
27
+ * dedupes against it.
10
28
  *
11
29
  * The registry rides a global symbol so two module instances of the same
12
- * package (npm copy vs repository link) still share one verdict. cordis
13
- * `ctx.effect` runs its callback immediately and treats the callback's
30
+ * package (npm copy vs repository link) still share one verdict. That symbol
31
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
32
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
33
+ * dsh-community-plugins) are separate repositories carrying their own copy of
34
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
35
+ * must keep the shape every published copy reads (a `Set` of package names
36
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
37
+ * under their own additive key, and the registry reads back a `Set` even when
38
+ * some other build left a different value there. Changing `MOUNTED`'s shape
39
+ * in place broke that contract once: a satellite's legacy copy created a
40
+ * `Set`, the family's new copy read it as a `Map`, and every family row
41
+ * mounted after it failed with "claims.get is not a function".
42
+ *
43
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
14
44
  * return value as the fiber disposer, so the unmarker is returned, not run.
15
45
  */
16
46
  /**
17
47
  * Wrap a cordis plugin apply so the package runs at most once per process.
18
- * The first mount registers normally and unmarks when its fiber disposes;
19
- * any later mount of the same package name is a no-op.
48
+ * The first mount registers normally and releases the name when its fiber
49
+ * disposes; a mount refused while that name is held waits for the release and
50
+ * then runs, unless its own fiber disposes first.
20
51
  * @param packageName - npm package identity shared by every install source.
21
52
  * @param fn - the original plugin apply.
22
53
  * @returns an apply of the same shape.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.4.2",
4
+ "version": "0.4.3",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -34,6 +34,7 @@
34
34
  "@deepseek-ai/dsh-client-connection",
35
35
  "@deepseek-ai/dsh-client-ui-settings",
36
36
  "@deepseek-ai/dsh-client-ui-renderer",
37
+ "@deepseek-ai/dsh-client-ui-layout",
37
38
  "@deepseek-ai/dsh-api-session-controller",
38
39
  "@deepseek-ai/dsh-api-workspace-controller",
39
40
  "@deepseek-ai/dsh-api-remotes",
@@ -60,8 +61,10 @@
60
61
  "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
61
62
  "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
62
63
  "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.7-rc.2",
63
65
  "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
64
66
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
65
68
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
66
69
  "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.2",
67
70
  "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
@@ -4,135 +4,30 @@
4
4
  * board follows the active theme (light/dark and skins).
5
5
  */
6
6
 
7
- /* --- center-column takeover (global rules, attribute-scoped) ----------------- */
7
+ /* --- native panel page ------------------------------------------------------- */
8
8
 
9
- [data-pane='conversation'],
10
- [class*='centerCol'] {
11
- position: relative;
12
- }
9
+ /* The board is an official center-column panel: the layout mounts this element
10
+ as the selected keyed 'main' occupant, so the shell's own column already
11
+ owns position, sizing and the conversation underneath. Nothing here hides or
12
+ re-parents another plugin's surface — the previous DOM takeover (absolute
13
+ overlay over a hidden conversation subtree) is gone with it.
13
14
 
14
- /* The board container rides inside the conversation grid item as an extra
15
- trailing child; hidden unless the board is active. */
15
+ The container query context stays: the board's responsive rules measure the
16
+ PANEL rather than the viewport, so a narrow column or a resized window
17
+ narrows the board at the same threshold the shell uses for its columns. */
16
18
  [data-dsh-taskboard-view] {
17
- position: absolute;
18
- inset: 0;
19
- display: none;
20
- z-index: 60;
19
+ display: flex;
20
+ flex-direction: column;
21
+ box-sizing: border-box;
22
+ width: 100%;
23
+ height: 100%;
24
+ min-width: 0;
25
+ min-height: 0;
21
26
  container-name: task-board-view;
22
27
  container-type: inline-size;
23
- /* Opaque backdrop: the conversation subtree stays mounted under the view.
24
- Even if the hide rule below were defeated by a stronger host inline
25
- style, this background keeps sticky code-block banners (host z-index 6)
26
- from showing through the active board (issue #100). */
27
28
  background: var(--dsw-alias-bg-base);
28
29
  }
29
30
 
30
- /* The center column is single-occupant; the :not() guards keep the two
31
- sibling panels (task board / ssh) from fighting over visibility if both
32
- activation attributes ever coexist. */
33
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-dsh-taskboard-view] {
34
- display: block;
35
- }
36
-
37
- /* While the board is active, the conversation content underneath is hidden
38
- (it stays mounted and stateful). The !important is required: the dsh shell
39
- (0.1.0-rc.6) wraps the conversation view in a node with an inline
40
- `display: contents`, and inline styles beat a plain stylesheet rule. Without
41
- it the composer (input card) stays visible at the bottom and paints over the
42
- board modals' footer, hiding the run/delete buttons (issue #76).
43
- The rule targets both the pane attribute and the center-column class so the
44
- chat subtree (including the code-block copy banner, issue #100) stays hidden
45
- whichever host container carries it. */
46
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-taskboard-view]),
47
- html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol'] > :not([data-dsh-taskboard-view]) {
48
- display: none !important;
49
- }
50
-
51
- /* --- sidebar entry row ------------------------------------------------------- */
52
-
53
- /* Same geometry as the shell's own panel rows (SidebarRoot .panelRow): 2px
54
- inset with a 12px radius, 8px content padding, 36px row, 14/22 type, and the
55
- primary ink — so an injected row and an official row line up column for
56
- column instead of each carrying its own box. */
57
- .entry {
58
- box-sizing: border-box;
59
- display: flex;
60
- align-items: center;
61
- gap: 8px;
62
- min-height: 36px;
63
- padding: 7px 8px;
64
- margin: 0 2px;
65
- background: transparent;
66
- border: none;
67
- border-radius: 12px;
68
- color: var(--dsw-alias-label-primary);
69
- cursor: pointer;
70
- font: inherit;
71
- font-size: 14px;
72
- line-height: 22px;
73
- text-align: left;
74
- white-space: nowrap;
75
- }
76
-
77
- .entry:hover {
78
- background: var(--dsw-alias-interactive-bg-hover);
79
- color: var(--dsw-alias-label-primary);
80
- }
81
-
82
- .entry[data-active] {
83
- background: var(--dsw-alias-interactive-bg-active);
84
- color: var(--dsw-alias-label-primary);
85
- font-weight: 600;
86
- }
87
-
88
- /* Icon box matches the shell's panel glyph exactly (16px), so the glyph column
89
- and the label column land on the official rows' x positions. */
90
- .entryIcon {
91
- display: inline-flex;
92
- align-items: center;
93
- justify-content: center;
94
- width: 16px;
95
- height: 16px;
96
- flex: none;
97
- }
98
-
99
- .entryIcon svg {
100
- display: block;
101
- width: 16px;
102
- height: 16px;
103
- }
104
-
105
- .entryLabel {
106
- overflow: hidden;
107
- text-overflow: ellipsis;
108
- }
109
-
110
- /* Collapsed rail: icon-only, centered, matching the shell's 56px rail. The
111
- shell asks its own panel rows for an 18px glyph on the rail, so the icon box
112
- and the svg step up together with the 12px rail radius. */
113
- :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
114
- :global([data-sidebar-collapsed]) .entry {
115
- justify-content: center;
116
- padding: 0;
117
- width: 36px;
118
- height: 36px;
119
- margin: 0 auto 12px;
120
- border-radius: 12px;
121
- }
122
-
123
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon,
124
- :global([data-sidebar-collapsed]) .entryIcon,
125
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon svg,
126
- :global([data-sidebar-collapsed]) .entryIcon svg {
127
- width: 18px;
128
- height: 18px;
129
- }
130
-
131
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
132
- :global([data-sidebar-collapsed]) .entryLabel {
133
- display: none;
134
- }
135
-
136
31
  /* --- board frame -------------------------------------------------------------- */
137
32
 
138
33
  .board {
@@ -924,7 +819,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
924
819
  height: 100dvh;
925
820
  }
926
821
 
927
- .entry,
928
822
  .card,
929
823
  .primaryButton,
930
824
  .ghostButton,
@@ -1038,7 +932,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1038
932
  * --------------------------------------------------------------------------- */
1039
933
 
1040
934
  /* --- one themed focus-visible ring (approx 2px, 2px offset) --- */
1041
- .entry:focus-visible,
1042
935
  .card:focus-visible,
1043
936
  .primaryButton:focus-visible,
1044
937
  .ghostButton:focus-visible,
@@ -1055,7 +948,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1055
948
  }
1056
949
 
1057
950
  /* --- unified 120ms motion across the board's interactive controls --- */
1058
- .entry,
1059
951
  .primaryButton,
1060
952
  .ghostButton,
1061
953
  .dangerButton,
@@ -1075,7 +967,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1075
967
  transform: translateY(0);
1076
968
  }
1077
969
 
1078
- .entry:active,
1079
970
  .primaryButton:active:not(:disabled),
1080
971
  .ghostButton:active:not(:disabled),
1081
972
  .dangerButton:active:not(:disabled),
@@ -1084,11 +975,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1084
975
  transform: translateY(1px);
1085
976
  }
1086
977
 
1087
- /* --- entry row (sidebar) states --- */
1088
- .entry[data-active]:hover {
1089
- background: var(--dsw-specific-sidebar-nav-item-active);
1090
- }
1091
-
1092
978
  /* --- guarded hover for the remaining icon/link buttons (defensive: never
1093
979
  disabled today, but a disabled element must not react to hover) --- */
1094
980
  .iconButton:hover:not(:disabled) {
@@ -1121,7 +1007,6 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1121
1007
 
1122
1008
  /* --- reduced motion: strip transitions, stop the decorative spinner --- */
1123
1009
  @media (prefers-reduced-motion: reduce) {
1124
- .entry,
1125
1010
  .card,
1126
1011
  .primaryButton,
1127
1012
  .ghostButton,
@@ -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
@@ -362,12 +374,24 @@ export function apply(ctx: ClientContext): void {
362
374
  }
363
375
  void pushModelOptions()
364
376
  disposers.push(ctx.on('connection/reset', () => { void pushModelOptions() }))
377
+ // Native panel surfaces: one sidebar row and one center-column page, both
378
+ // through the official slot seats the shell itself renders. The layout's
379
+ // panel selection is reconciled back into the controller so the board's own
380
+ // view state follows the user clicking another panel row.
365
381
  try {
366
- disposers.push(mountSidebarEntry(controller, ctx.locale))
367
- disposers.push(mountBoard(controller, ctx.locale))
382
+ disposers.push(registerTaskBoardPanel(ctx, controller))
383
+ const layoutFace = ctx.get('layout') as { panelInfo?: { subscribe(listener: () => void): () => void; getSnapshot(): { activePanelId: unknown } } } | undefined
384
+ if (layoutFace?.panelInfo !== undefined) {
385
+ const sync = (): void => {
386
+ const active = layoutFace.panelInfo!.getSnapshot().activePanelId
387
+ controller.syncPanelSelection(active === TASK_BOARD_PANEL_ID ? TASK_BOARD_PANEL_ID : null)
388
+ }
389
+ sync()
390
+ disposers.push(layoutFace.panelInfo.subscribe(sync))
391
+ }
368
392
  } catch (error) {
369
- // DOM failures degrade the board, never the GUI.
370
- console.error('[dsh-task-board] mount failed:', error)
393
+ // A shell that cannot serve the seats degrades the board, never the GUI.
394
+ console.error('[dsh-task-board] panel registration failed:', error)
371
395
  }
372
396
 
373
397
  uiDisposer = () => {
@@ -415,22 +439,26 @@ export function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettin
415
439
  if (binder !== undefined && typeof binder.bind === 'function') {
416
440
  return binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
417
441
  }
418
- return ctx.configForms.get<TaskBoardSettings>(servedEntryId(ctx.configForms))
442
+ return createServedEntryForm<TaskBoardSettings>({
443
+ forms: ctx.configForms,
444
+ entryIds: TASK_BOARD_ENTRY_IDS,
445
+ })
419
446
  }
420
447
 
421
448
  /**
422
- * The profile entry id this package's own row carries.
449
+ * The profile entry id this package's own row carries, for a page that serves
450
+ * no family binder.
423
451
  *
424
452
  * 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.
453
+ * profile actually serves, but it answers asynchronously: at plugin activation
454
+ * it usually holds nothing yet. An unanswered mirror therefore binds the
455
+ * aggregate row id rather than guessing among the candidates, and the binding
456
+ * is re-resolved once the mirror answers — see
457
+ * {@link createServedEntryForm}, which owns that rebinding.
430
458
  * @param forms - the shared configuration forms service.
431
- * @returns the entry id to bind.
459
+ * @returns the entry id to bind before the mirror answers.
432
460
  */
433
- function servedEntryId(forms: ConfigForms): string {
461
+ export function servedEntryId(forms: ConfigForms): string {
434
462
  let served: readonly string[] | undefined
435
463
  try {
436
464
  served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Native panel registration for the task board.
3
+ *
4
+ * The board is an official-style center-column panel, not a DOM takeover: it
5
+ * contributes a row into the sidebar shell's own global panel list
6
+ * (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
7
+ * shell then owns the row box, the label, the active highlight, the collapsed
8
+ * rail, the panel switch and the window-chrome interplay exactly as it does for
9
+ * the shipped Plugins and Schedule pages, so the board renders through the same
10
+ * container the desktop application's Plugins page uses.
11
+ *
12
+ * Both registrations go through `ctx.slots.inject`, which fires only once the
13
+ * owning shell entry has declared the seat: load order between this plugin and
14
+ * ui-layout / ui-sidebar therefore does not matter, and neither does a shell
15
+ * that cannot serve the seats (the callback simply never runs).
16
+ *
17
+ * @module @linxin666/dsh-client-ui-task-board/client
18
+ */
19
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
20
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
21
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
22
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
23
+ import { TASK_BOARD_PANEL_ID, type BoardController } from '../core/controller.ts'
24
+ import { TaskBoard } from './board/TaskBoard.tsx'
25
+ import { t } from './locales.ts'
26
+ import css from './board.module.css'
27
+
28
+ /** The panel id shared by the sidebar row and the main-slot occupant. */
29
+ export { TASK_BOARD_PANEL_ID }
30
+
31
+ /** Row order among the shell's global panel rows (Plugins is 0, Schedule 10). */
32
+ const PANEL_ORDER = 20
33
+
34
+ /**
35
+ * The sidebar row glyph the shell asks for at its own size and active state.
36
+ * The shell owns the button, label, tooltip and rail geometry; this component
37
+ * draws only the glyph, like every other panel row.
38
+ *
39
+ * The glyph carries `data-dsh-panel-entry` because it is the only DOM the
40
+ * panel's own code owns inside that shell-owned row: the L2 contract (skins)
41
+ * resolves which row belongs to which plugin through it, since the shell
42
+ * stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
43
+ * @param props - the shell's icon share: square edge and selection state.
44
+ * @returns the decorative board glyph.
45
+ */
46
+ export function TaskBoardPanelIcon({ size }: { size: number; active: boolean }): React.ReactElement {
47
+ return (
48
+ <svg
49
+ data-dsh-panel-entry={TASK_BOARD_PANEL_ID}
50
+ viewBox="0 0 16 16"
51
+ width={size}
52
+ height={size}
53
+ fill="none"
54
+ stroke="currentColor"
55
+ strokeWidth="1.3"
56
+ strokeLinecap="round"
57
+ strokeLinejoin="round"
58
+ aria-hidden="true"
59
+ >
60
+ <rect x="2" y="2.5" width="12" height="11" rx="1.5" />
61
+ <path d="M2 6.5h12M6.5 6.5v7" />
62
+ </svg>
63
+ )
64
+ }
65
+
66
+ /**
67
+ * The main-slot page. The layout mounts it only while the board is the selected
68
+ * panel, so the conversation keeps the center column untouched the rest of the
69
+ * time; the wrapper carries the pinned `data-dsh-taskboard-view` semantic
70
+ * anchor (L2 contract, skins) and the container query context the board's
71
+ * responsive rules read.
72
+ * @param props - the framework main-slot share plus this entry's injected face.
73
+ * @returns the board page.
74
+ */
75
+ export function TaskBoardPanel({ controller }: { controller: BoardController }): React.ReactElement {
76
+ return (
77
+ <div className={css.panel} data-dsh-taskboard-view="" data-dsh-plugin="task-board">
78
+ <TaskBoard controller={controller} />
79
+ </div>
80
+ )
81
+ }
82
+
83
+ /** The injected face both registrations receive. */
84
+ interface PanelFace {
85
+ controller: BoardController
86
+ }
87
+
88
+ /**
89
+ * Register the board's sidebar row and center-column page.
90
+ *
91
+ * Both seats are declared by shell plugins this package does not depend on at
92
+ * runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
93
+ * runs only after the owning entry declares the seat, and a shell that never
94
+ * declares it leaves the board simply absent instead of failing boot.
95
+ * @param ctx - client root context (services: slots).
96
+ * @param controller - the board controller the page and the row drive.
97
+ * @returns disposer releasing both registrations.
98
+ */
99
+ export function registerTaskBoardPanel(ctx: ClientContext, controller: BoardController): () => void {
100
+ const slots = ctx.slots as {
101
+ inject(key: string, callback: () => () => void): () => void
102
+ register(options: Record<string, unknown>, component: unknown): () => void
103
+ }
104
+ const disposers: Array<() => void> = []
105
+
106
+ disposers.push(slots.inject('sidebar.panellist', () => slots.register({
107
+ name: 'sidebar.panellist',
108
+ id: TASK_BOARD_PANEL_ID,
109
+ order: PANEL_ORDER,
110
+ // The shell resolves this through resolveSlotLabel on every locale change,
111
+ // so the module-level translate reads the active language at call time.
112
+ label: () => t('entry.label'),
113
+ }, TaskBoardPanelIcon)))
114
+
115
+ disposers.push(slots.inject('main', () => slots.register({
116
+ name: 'main',
117
+ key: TASK_BOARD_PANEL_ID,
118
+ inject: (): PanelFace => ({ controller }),
119
+ }, TaskBoardPanel as never)))
120
+
121
+ return () => {
122
+ for (const dispose of disposers.splice(0)) dispose()
123
+ }
124
+ }