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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. package/src/client/sidebar-entry.ts +0 -53
@@ -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 = () => {
@@ -384,7 +408,17 @@ export function apply(ctx: ClientContext): void {
384
408
  if (enabled) mountUi()
385
409
  else uiDisposer?.()
386
410
  }
387
- settingsForm.subscribe(syncEnabled)
411
+ const unsubscribeSettings = settingsForm.subscribe(syncEnabled)
412
+ // The mounted surfaces and the settings subscription belong to this fiber.
413
+ // The loader replaces a rebuilt bundle IN PLACE (fiber teardown and re-apply
414
+ // without a page reload), and that same teardown removes the dictionaries
415
+ // this row and board render through — a row left behind would stay visible
416
+ // showing raw dictionary keys, and a surviving subscription could mount a
417
+ // second one from a dead instance.
418
+ ctx.effect(() => () => {
419
+ unsubscribeSettings()
420
+ uiDisposer?.()
421
+ }, 'task-board: DOM surfaces')
388
422
  syncEnabled()
389
423
  }
390
424
 
@@ -405,22 +439,26 @@ export function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettin
405
439
  if (binder !== undefined && typeof binder.bind === 'function') {
406
440
  return binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
407
441
  }
408
- return ctx.configForms.get<TaskBoardSettings>(servedEntryId(ctx.configForms))
442
+ return createServedEntryForm<TaskBoardSettings>({
443
+ forms: ctx.configForms,
444
+ entryIds: TASK_BOARD_ENTRY_IDS,
445
+ })
409
446
  }
410
447
 
411
448
  /**
412
- * 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.
413
451
  *
414
452
  * The shared describe mirror is the only local evidence of which row id this
415
- * profile actually serves, but it answers asynchronously: at plugin
416
- * activation it usually holds nothing yet. An unanswered mirror therefore
417
- * binds the aggregate row id rather than guessing among the candidates —
418
- * the form is bound once for the session, so a wrong guess would leave the
419
- * 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.
420
458
  * @param forms - the shared configuration forms service.
421
- * @returns the entry id to bind.
459
+ * @returns the entry id to bind before the mirror answers.
422
460
  */
423
- function servedEntryId(forms: ConfigForms): string {
461
+ export function servedEntryId(forms: ConfigForms): string {
424
462
  let served: readonly string[] | undefined
425
463
  try {
426
464
  served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
@@ -157,12 +157,16 @@ export const zh = {
157
157
  'exec.mode.brokenSuffix': '(不可用)',
158
158
  'exec.mode.removed': '(已移除)',
159
159
  'exec.permission.default': '会话默认',
160
+ 'exec.permission.inheritParent': '继承父任务({permission})',
160
161
  'exec.permission.read-only': '只读',
161
162
  'exec.permission.workspace-write': '工作区可写',
162
163
  'exec.permission.danger-full-access': '完全访问',
163
164
  'exec.model.default': '宿主默认(agent-default-model)',
164
165
  'exec.model.unknown': '(未知模型/回退默认)',
165
166
  'exec.reuseSession': '在同一对话继续',
167
+ 'exec.teamRun': '用 Agent Team 执行(Leader/teammate)',
168
+ 'exec.teamRunHint': '开启后:执行本任务只启动一个 Leader 会话,每个子任务由它派生为 teammate 并在该会话内并行工作(子任务自身的权限钉住不适用)。关闭则每个成员各开独立会话。',
169
+ 'exec.teamRunUnavailable': '当前部署未提供 Agent Teams 服务,无法启用。',
166
170
  'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
167
171
  'detail.executionSettings': '执行设置',
168
172
  'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
@@ -170,7 +174,7 @@ export const zh = {
170
174
  'settings.title': '任务看板',
171
175
  'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
172
176
  'settings.enabled': '启用任务看板',
173
- 'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图。',
177
+ 'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图,并注销 task_board_* agent 工具;开启时任何会话都可以通过这些工具读写看板、子任务与定时计划。',
174
178
  'settings.announceToAgent': '向 agent 播报任务看板',
175
179
  'settings.announceToAgentHint': '开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。',
176
180
  'settings.preventIdleSleep': '阻止系统空闲睡眠',
@@ -194,6 +198,36 @@ export const zh = {
194
198
  'settings.unsaved': '未保存',
195
199
  'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
196
200
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
201
+ 'settings.maxSubtaskDepth': '子任务深度上限',
202
+ 'settings.maxSubtaskDepthHint': '默认 1:一个任务只允许一层子任务,子任务不能再创建或关联子任务。最大 3。执行父任务会并发执行它的整棵子任务树,层级越深,一次执行开启的会话越多。',
203
+ 'settings.maxSubtaskDepthOption': '{depth} 层',
204
+ // 子任务(父子关系、级联执行、关联/解除关联)。
205
+ 'detail.parent': '父任务',
206
+ 'detail.parent.open': '打开父任务',
207
+ 'detail.subtasks': '子任务',
208
+ 'detail.subtasks.empty': '还没有子任务',
209
+ 'detail.subtasks.add': '添加子任务',
210
+ 'detail.subtasks.link': '关联现有任务',
211
+ 'detail.subtasks.detach': '解除关联',
212
+ 'detail.subtasks.runningLock': '子任务正在执行,结算后才能解除关联或改挂父任务',
213
+ 'detail.subtasks.runHint': '执行本任务会同时并发执行它的 {count} 个子任务;子任务失败不影响其它子任务继续执行。',
214
+ 'detail.subtasks.waiting': '等待子任务结算',
215
+ 'detail.subtasks.depthLimit': '当前子任务深度上限为 {depth} 层,子任务不能再创建或关联子任务。',
216
+ 'detail.subtasks.hostOnly': '连接 Host 后才能创建或关联子任务。',
217
+ 'new.subtaskTitle': '新建子任务',
218
+ 'new.subtaskInherit': '未单独选择的执行设置继承父任务(工作区、模式、权限、模型),可在此覆盖。',
219
+ 'link.subtask.title': '关联现有任务',
220
+ 'link.subtask.hint': '只能关联没有父任务的在看板任务;关联后它成为本任务的子任务。',
221
+ 'link.subtask.empty': '没有可关联的任务',
222
+ 'link.subtask.confirm': '关联',
223
+ 'card.subtask': '子任务',
224
+ 'card.subtasks': '{count} 子任务',
225
+ 'card.subtasksFailed': '{count} 失败',
226
+ 'card.subtasksRunning': '{count} 运行中',
227
+ 'card.subtasksBreakdown': '子任务 {total}:已完成 {done},运行中 {running},失败 {failed}',
228
+ 'board.hideSubtasks': '隐藏子任务',
229
+ 'board.showSubtasks': '显示子任务',
230
+ 'board.subtaskFilterHint': '看板默认只显示父任务;搜索或按标签筛选时会自动展开子任务。',
197
231
  } satisfies Record<string, string>
198
232
 
199
233
  /** en dictionary, complete against the zh key set. */
@@ -348,12 +382,16 @@ export const en: Record<keyof typeof zh, string> = {
348
382
  'exec.mode.brokenSuffix': ' (unavailable)',
349
383
  'exec.mode.removed': ' (removed)',
350
384
  'exec.permission.default': 'Session default',
385
+ 'exec.permission.inheritParent': 'Inherit parent ({permission})',
351
386
  'exec.permission.read-only': 'Read-only',
352
387
  'exec.permission.workspace-write': 'Workspace Write',
353
388
  'exec.permission.danger-full-access': 'Full Access',
354
389
  'exec.model.default': 'Host default (agent-default-model)',
355
390
  'exec.model.unknown': ' (unknown / fallback to default)',
356
391
  'exec.reuseSession': 'Continue in the same conversation',
392
+ 'exec.teamRun': 'Run with an Agent Team (Lead + teammates)',
393
+ 'exec.teamRunHint': 'On: running this task starts one Lead session and spawns a teammate per subtask inside it, working in parallel (a subtask permission pin does not apply). Off: every member gets its own independent session.',
394
+ 'exec.teamRunUnavailable': 'This deployment serves no Agent Teams service, so team runs cannot be enabled.',
357
395
  'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
358
396
  'detail.executionSettings': 'Execution Settings',
359
397
  'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
@@ -361,7 +399,7 @@ export const en: Record<keyof typeof zh, string> = {
361
399
  'settings.title': 'Task Board',
362
400
  'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
363
401
  'settings.enabled': 'Enable the task board',
364
- 'settings.enabledHint': 'When off, the sidebar entry and board view are hidden.',
402
+ 'settings.enabledHint': 'When off, the sidebar entry and board view are hidden and the task_board_* agent tools are unregistered; while on, any session can read and drive the board, its subtasks and its schedules through those tools.',
365
403
  'settings.announceToAgent': 'Announce the task board to agents',
366
404
  'settings.announceToAgentHint': 'On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.',
367
405
  'settings.preventIdleSleep': 'Prevent idle system sleep',
@@ -385,6 +423,36 @@ export const en: Record<keyof typeof zh, string> = {
385
423
  'settings.unsaved': 'Unsaved',
386
424
  'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
387
425
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
426
+ 'settings.maxSubtaskDepth': 'Subtask depth limit',
427
+ 'settings.maxSubtaskDepthHint': 'Default 1: a task may carry one level of subtasks, and a subtask cannot create or link further subtasks. Maximum 3. Running a task also runs its whole subtask tree concurrently, and every extra level opens more sessions per run.',
428
+ 'settings.maxSubtaskDepthOption': '{depth} levels',
429
+ // Subtasks (parent/child links, cascade runs, link/detach).
430
+ 'detail.parent': 'Parent task',
431
+ 'detail.parent.open': 'Open parent task',
432
+ 'detail.subtasks': 'Subtasks',
433
+ 'detail.subtasks.empty': 'No subtasks yet',
434
+ 'detail.subtasks.add': 'Add subtask',
435
+ 'detail.subtasks.link': 'Link existing task',
436
+ 'detail.subtasks.detach': 'Detach',
437
+ 'detail.subtasks.runningLock': 'This subtask is running; it can be detached or re-linked once it settles',
438
+ 'detail.subtasks.runHint': 'Running this task also runs its {count} subtasks concurrently; one subtask failing does not stop the others.',
439
+ 'detail.subtasks.waiting': 'Waiting for subtasks',
440
+ 'detail.subtasks.depthLimit': 'The subtask depth limit is {depth}; a subtask cannot create or link further subtasks.',
441
+ 'detail.subtasks.hostOnly': 'Connect to the Host to create or link subtasks.',
442
+ 'new.subtaskTitle': 'New Subtask',
443
+ 'new.subtaskInherit': 'Execution settings left unset inherit the parent (workspace, mode, permission, model); override any of them here.',
444
+ 'link.subtask.title': 'Link an existing task',
445
+ 'link.subtask.hint': 'Only an on-board task without a parent can be linked; it then becomes a subtask of this task.',
446
+ 'link.subtask.empty': 'No task can be linked',
447
+ 'link.subtask.confirm': 'Link',
448
+ 'card.subtask': 'subtask',
449
+ 'card.subtasks': '{count} subtasks',
450
+ 'card.subtasksFailed': '{count} failed',
451
+ 'card.subtasksRunning': '{count} running',
452
+ 'card.subtasksBreakdown': 'Subtasks {total}: {done} done, {running} running, {failed} failed',
453
+ 'board.hideSubtasks': 'Hide subtasks',
454
+ 'board.showSubtasks': 'Show subtasks',
455
+ 'board.subtaskFilterHint': 'The board shows parent tasks only; a text or label filter reveals the subtasks automatically.',
388
456
  }
389
457
 
390
458
  /** The dictionary key union. */
@@ -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
+ }
@@ -0,0 +1,142 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
3
+ /**
4
+ * Family settings form bound to the profile entry id a Host actually serves.
5
+ *
6
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
7
+ * a family card knows only its own settings namespace. The family binder
8
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
9
+ * the other and is the preferred transport; this module is the fallback for a
10
+ * page that serves no binder — the group plugin is not installed, or its client
11
+ * half has not applied yet (the aggregate mounts its children in order, and a
12
+ * child still waiting on its own injects publishes nothing).
13
+ *
14
+ * The shared describe mirror is the only local evidence of which row id this
15
+ * profile serves, but it answers asynchronously: at plugin activation it usually
16
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
17
+ * deployment, and the form is bound once per session:
18
+ *
19
+ * - binding the bare namespace made every save answer `No configurable plugin
20
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
21
+ * id there), which the card reported as "the deployment did not accept these
22
+ * values";
23
+ * - binding the aggregate row id would break a standalone install, whose row id
24
+ * is the namespace or the package's own `ui-*` row.
25
+ *
26
+ * So the fallback starts on the most likely row and REBINDS the moment the
27
+ * mirror names one of this package's rows. Until the mirror answers, the form
28
+ * reports the binding it holds (usually `loading`), which the card renders as an
29
+ * unavailable form rather than a doomed one.
30
+ */
31
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
32
+
33
+ /** Options of {@link createServedEntryForm}. */
34
+ export interface ServedEntryFormOptions<T> {
35
+ /** The shared configuration forms service (`ctx.configForms`). */
36
+ forms: ConfigForms
37
+ /**
38
+ * Profile entry ids this package's rows carry, most likely first: the
39
+ * aggregate's generated row, the package's own standalone row, then the bare
40
+ * namespace. The first is the fallback while the mirror has no answer; the
41
+ * last is the answer when the mirror serves none of them.
42
+ */
43
+ entryIds: readonly string[]
44
+ }
45
+
46
+ /** The snapshot a form reports before the Host has answered with this entry. */
47
+ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
48
+ return {
49
+ status: 'loading',
50
+ value: undefined,
51
+ base: undefined,
52
+ user: undefined,
53
+ revision: undefined,
54
+ writable: false,
55
+ mode: 'host',
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create a settings form bound to the profile entry id this Host serves, and
61
+ * rebound whenever the shared describe mirror names a different one of this
62
+ * package's rows.
63
+ * @param options - the shared forms service and this package's candidate row ids.
64
+ * @returns a form delegating to the currently resolved entry's own form.
65
+ */
66
+ export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
67
+ const { forms, entryIds } = options
68
+ const fallbackId = entryIds[0]
69
+ const namespaceId = entryIds[entryIds.length - 1]
70
+ const listeners = new Set<() => void>()
71
+ let boundId: string | undefined
72
+ let bound: ConfigForm<T> | undefined
73
+ let offBound: (() => void) | undefined
74
+ let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
75
+
76
+ /** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
77
+ const publish = (): void => {
78
+ if (bound !== undefined) snapshot = bound.getSnapshot()
79
+ for (const listener of [...listeners]) listener()
80
+ }
81
+
82
+ /**
83
+ * The entry id the mirror currently justifies. An unanswered or empty mirror
84
+ * is not evidence of absence, so it keeps the first candidate; a mirror that
85
+ * answers without any of this package's rows leaves the namespace itself.
86
+ * @returns the entry id to bind.
87
+ */
88
+ const resolve = (): string => {
89
+ let served: readonly string[] | undefined
90
+ try {
91
+ served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
92
+ } catch {
93
+ // A mirror that refuses the read is unanswered too, not absent.
94
+ served = undefined
95
+ }
96
+ if (served === undefined || served.length === 0) return fallbackId
97
+ return entryIds.find(id => served.includes(id)) ?? namespaceId
98
+ }
99
+
100
+ /** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
101
+ const bind = (): void => {
102
+ const target = resolve()
103
+ if (target === boundId) return
104
+ offBound?.()
105
+ offBound = undefined
106
+ let form: ConfigForm<T>
107
+ try {
108
+ form = forms.get<T>(target)
109
+ } catch {
110
+ // A service that refuses the id stays unbound; the next mirror answer
111
+ // retries rather than pinning a wrong entry for the session.
112
+ boundId = undefined
113
+ return
114
+ }
115
+ boundId = target
116
+ bound = form
117
+ offBound = form.subscribe(() => { publish() })
118
+ publish()
119
+ }
120
+
121
+ // The mirror subscription lives for the page: the card owns its form for the
122
+ // session, and the ConfigForm contract carries no disposer to hang it on. The
123
+ // callback is cheap (a resolve plus a no-op while the id is unchanged). A
124
+ // mirror without a subscription seat keeps the initial binding.
125
+ try {
126
+ forms.describe().subscribe(() => { bind() })
127
+ } catch {
128
+ // A describe face that refuses the subscription: the initial binding stands.
129
+ }
130
+ bind()
131
+
132
+ return {
133
+ getSnapshot: () => snapshot,
134
+ subscribe: (listener) => {
135
+ listeners.add(listener)
136
+ return () => { listeners.delete(listener) }
137
+ },
138
+ set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
139
+ unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
140
+ mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
141
+ }
142
+ }