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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -29,8 +29,11 @@ export const zh = {
29
29
  'board.updated': '更新于',
30
30
  'board.created': '创建于',
31
31
  'board.hostError': 'Host 操作失败:{error}',
32
- 'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主可能没有加载该插件,也可能后台数据正被另一个 DSH 实例占用。重启 DSH 服务后再试',
32
+ 'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主没有加载这个插件(可能未安装、未启用,或该行启动失败)。重启 DSH 服务后再试',
33
+ 'board.hostError.ledgerLocked': '任务看板的账本已被另一个 DSH 进程占用(占用者 pid {pid}),本实例没有注册后台接口:关闭那个 DSH 实例,或等它退出后再点重试——反复重启本实例无效(宿主原因:{detail})',
34
+ 'board.hostError.degraded': '任务看板这一行在启动时降级,后台接口没有注册(宿主原因:{detail})',
33
35
  'board.hostError.unauthorized': '登录状态已失效,请刷新页面后重试',
36
+ 'board.hostError.forbidden': '当前环境未通过同源校验:桌面外壳或反向代理可能未传递浏览器标记,也没有携带宿主认证凭据',
34
37
  'board.hostError.locked': '任务看板的后台数据被占用:{detail}',
35
38
  'board.hostError.timeout': '后台 {seconds} 秒内没有响应,请稍后重试',
36
39
  'board.hostError.unreachable': '连接不上本机 DSH 服务,请确认它仍在运行',
@@ -55,6 +58,20 @@ export const zh = {
55
58
  'new.aiParseFailed': '解析失败:{error}',
56
59
  'board.retryHost': '重试连接 Host',
57
60
  'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
61
+ // 新建/编辑对话框的区域分组:折叠后每个区域仍以一行摘要显示当前配置。
62
+ 'new.section.parse': 'AI 解析',
63
+ 'new.section.content': '任务内容',
64
+ 'new.section.labels': '标签',
65
+ 'new.section.execution': '执行设置',
66
+ 'new.section.run': '运行方式',
67
+ 'new.section.handover': '交接与续跑',
68
+ 'new.section.schedule': '定时运行',
69
+ 'new.summary.none': '未设置',
70
+ 'new.summary.labelCount': '{count} 个标签',
71
+ 'new.summary.filled': '已填写',
72
+ 'new.summary.scheduleOff': '未启用',
73
+ 'new.summary.multiRound': '多轮 /goal',
74
+ 'new.summary.singleRound': '单轮',
58
75
  'new.title': '标题',
59
76
  'new.titlePlaceholder': '一句话描述要做什么',
60
77
  'new.description': '描述',
@@ -146,13 +163,31 @@ export const zh = {
146
163
  'detail.schedule.invalid': 'Cron 表达式无效',
147
164
  'detail.schedule.notScheduled': '尚未排程',
148
165
  'detail.schedule.dueSoon': '即将运行',
166
+ 'detail.schedule.timeZone': '时区',
167
+ 'detail.schedule.timeZoneHost': 'Host 时区({timeZone})',
168
+ 'detail.schedule.timeZoneHostUnknown': 'Host 时区(未知)',
169
+ 'detail.schedule.timeZoneHint': 'Cron 的墙上时间按此时区解读;夏令时跳变会跳过不存在的时刻,重复的时刻只触发一次。',
170
+ 'detail.schedule.timeZoneSearch': '搜索时区…',
171
+ 'detail.schedule.timeZoneNone': '没有匹配的时区',
172
+ 'detail.schedule.nextRunOverdue': '已逾期 {duration}',
173
+ 'detail.schedule.duration.days': '{count} 天',
174
+ 'detail.schedule.duration.hours': '{count} 小时',
175
+ 'detail.schedule.duration.minutes': '{count} 分钟',
176
+ 'detail.schedule.duration.seconds': '{count} 秒',
149
177
  'card.scheduled': '定时',
150
178
  'new.workspace': '工作区',
151
- 'new.mode': '模式',
179
+ 'new.agentPreset': 'Agent 预设',
152
180
  'new.permission': '权限',
153
181
  'new.model': '模型',
154
182
  'exec.workspace.recent': '最近使用(默认)',
155
- 'exec.mode.default': '部署默认',
183
+ 'exec.mode.inherit': '继承(部署默认)',
184
+ 'exec.mode.inheritWith': '继承(部署默认:{preset})',
185
+ 'exec.mode.builtinGroup': '内置预设',
186
+ 'exec.mode.customGroup': '自定义预设',
187
+ 'preset.builtin.standard': '标准',
188
+ 'preset.builtin.ptc': 'PTC(程序化工具调用)',
189
+ 'preset.builtin.minimal': '极简',
190
+ 'preset.builtin.cordis': 'Cordis',
156
191
  'exec.mode.defaultSuffix': '(默认)',
157
192
  'exec.mode.brokenSuffix': '(不可用)',
158
193
  'exec.mode.removed': '(已移除)',
@@ -168,8 +203,10 @@ export const zh = {
168
203
  'exec.teamRunHint': '开启后:执行本任务只启动一个 Leader 会话,每个子任务由它派生为 teammate 并在该会话内并行工作(子任务自身的权限钉住不适用)。关闭则每个成员各开独立会话。',
169
204
  'exec.teamRunUnavailable': '当前部署未提供 Agent Teams 服务,无法启用。',
170
205
  'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
206
+ 'exec.goalRun': '以 dsh 内置的 /goal 开始执行任务',
207
+ 'exec.goalRunHint': '默认开启:执行时把任务目标作为持久目标交给 dsh 内置的 /goal,会话会自动续跑多轮,直到 agent 标记目标完成;看板在该目标真正结束后才结算本次执行。关闭则只执行一轮普通对话。',
171
208
  'detail.executionSettings': '执行设置',
172
- 'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
209
+ 'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;Agent 预设决定会话的组合(工具面与提示词段);权限经 /permission 命令应用到会话。留空则继承部署默认。',
173
210
  // 插件设置卡片(settings.plugin.item 席位)。
174
211
  'settings.title': '任务看板',
175
212
  'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
@@ -254,8 +291,11 @@ export const en: Record<keyof typeof zh, string> = {
254
291
  'board.updated': 'Updated',
255
292
  'board.created': 'Created',
256
293
  'board.hostError': 'Host action failed: {error}',
257
- 'board.hostError.notMounted': 'The task board Host API is not mounted: the Host may not have loaded the plugin, or another DSH instance holds the ledger. Restart the DSH service and try again',
294
+ 'board.hostError.notMounted': 'The task board Host API is not mounted: the Host did not load this plugin (not installed, disabled, or its row failed to start). Restart the DSH service and try again',
295
+ 'board.hostError.ledgerLocked': 'Another DSH process (pid {pid}) holds the task board ledger, so this instance registered no Host API: close that DSH instance, or wait for it to exit and retry. Restarting this instance cannot help (reason: {detail})',
296
+ 'board.hostError.degraded': 'The task board row degraded during start, so its Host API was not registered: {detail}',
258
297
  'board.hostError.unauthorized': 'This session is no longer signed in; reload the page and try again',
298
+ 'board.hostError.forbidden': 'The request did not pass the same-origin fence: the desktop shell or reverse proxy may not have forwarded browser credentials',
259
299
  'board.hostError.locked': 'The task board ledger is locked: {detail}',
260
300
  'board.hostError.timeout': 'The Host did not answer within {seconds}s; try again shortly',
261
301
  'board.hostError.unreachable': 'Cannot reach the local DSH service; make sure it is still running',
@@ -280,6 +320,20 @@ export const en: Record<keyof typeof zh, string> = {
280
320
  'new.aiParseFailed': 'Parsing failed: {error}',
281
321
  'board.retryHost': 'Retry Host connection',
282
322
  'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
323
+ // Create/edit dialog regions: a collapsed region still shows a one-line summary of its values.
324
+ 'new.section.parse': 'AI Parse',
325
+ 'new.section.content': 'Task Content',
326
+ 'new.section.labels': 'Tags',
327
+ 'new.section.execution': 'Execution Settings',
328
+ 'new.section.run': 'Run Mode',
329
+ 'new.section.handover': 'Handover',
330
+ 'new.section.schedule': 'Scheduled Runs',
331
+ 'new.summary.none': 'Not set',
332
+ 'new.summary.labelCount': '{count} tags',
333
+ 'new.summary.filled': 'Filled',
334
+ 'new.summary.scheduleOff': 'Off',
335
+ 'new.summary.multiRound': 'multi-round /goal',
336
+ 'new.summary.singleRound': 'single round',
283
337
  'new.title': 'Title',
284
338
  'new.titlePlaceholder': 'What should be done, in one line',
285
339
  'new.description': 'Description',
@@ -371,13 +425,31 @@ export const en: Record<keyof typeof zh, string> = {
371
425
  'detail.schedule.invalid': 'Invalid cron expression',
372
426
  'detail.schedule.notScheduled': 'Not scheduled yet',
373
427
  'detail.schedule.dueSoon': 'Due soon',
428
+ 'detail.schedule.timeZone': 'Time zone',
429
+ 'detail.schedule.timeZoneHost': 'Host time zone ({timeZone})',
430
+ 'detail.schedule.timeZoneHostUnknown': 'Host time zone (unknown)',
431
+ 'detail.schedule.timeZoneHint': 'The cron wall clock is read in this zone; daylight-saving gaps are skipped and a repeated time fires once.',
432
+ 'detail.schedule.timeZoneSearch': 'Search time zones…',
433
+ 'detail.schedule.timeZoneNone': 'No matching time zone',
434
+ 'detail.schedule.nextRunOverdue': 'Overdue by {duration}',
435
+ 'detail.schedule.duration.days': '{count}d',
436
+ 'detail.schedule.duration.hours': '{count}h',
437
+ 'detail.schedule.duration.minutes': '{count}m',
438
+ 'detail.schedule.duration.seconds': '{count}s',
374
439
  'card.scheduled': 'scheduled',
375
440
  'new.workspace': 'Workspace',
376
- 'new.mode': 'Mode',
441
+ 'new.agentPreset': 'Agent preset',
377
442
  'new.permission': 'Permission',
378
443
  'new.model': 'Model',
379
444
  'exec.workspace.recent': 'Most recent (default)',
380
- 'exec.mode.default': 'Deployment default',
445
+ 'exec.mode.inherit': 'Inherit (deployment default)',
446
+ 'exec.mode.inheritWith': 'Inherit (deployment default: {preset})',
447
+ 'exec.mode.builtinGroup': 'Built-in presets',
448
+ 'exec.mode.customGroup': 'Custom presets',
449
+ 'preset.builtin.standard': 'Standard',
450
+ 'preset.builtin.ptc': 'PTC (programmatic tool calling)',
451
+ 'preset.builtin.minimal': 'Minimal',
452
+ 'preset.builtin.cordis': 'Cordis',
381
453
  'exec.mode.defaultSuffix': ' (default)',
382
454
  'exec.mode.brokenSuffix': ' (unavailable)',
383
455
  'exec.mode.removed': ' (removed)',
@@ -393,8 +465,10 @@ export const en: Record<keyof typeof zh, string> = {
393
465
  '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
466
  'exec.teamRunUnavailable': 'This deployment serves no Agent Teams service, so team runs cannot be enabled.',
395
467
  '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.',
468
+ 'exec.goalRun': "Start the run with dsh's built-in /goal",
469
+ 'exec.goalRunHint': 'On by default: the run arms dsh built-in /goal with the task objective, so the session keeps working automatic continuation rounds until the agent marks the goal complete, and the board settles the execution only when that goal really ends. Off: one plain turn.',
396
470
  'detail.executionSettings': 'Execution Settings',
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.',
471
+ 'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the agent preset composes the session (tool face and prompt sections); the permission is applied through the /permission command. Blank inherits the deployment default.',
398
472
  // Plugin settings card (the `settings.plugin.item` seat).
399
473
  'settings.title': 'Task Board',
400
474
  'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
@@ -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,135 @@
1
+ /**
2
+ * Shared scheduling helpers for the board's schedule editors (the new-task
3
+ * dialog and the task detail panel): the time-zone picker inventory, the
4
+ * relative next-run wording, and the combined absolute + relative label.
5
+ *
6
+ * Everything here is browser-side presentation built on the same pure engine
7
+ * the Host schedules with (`core/schedule.ts`), so the preview a user sees is
8
+ * computed by the code that will actually arm the rule.
9
+ */
10
+ import { isValidTimeZone } from '../core/schedule.ts'
11
+ import { t } from './locales.ts'
12
+
13
+ /** One selectable zone, in the order the picker shows it. */
14
+ export interface ZoneChoice {
15
+ /** Canonical IANA id, or `''` for the Host-zone entry. */
16
+ id: string
17
+ /** Display label. */
18
+ label: string
19
+ }
20
+
21
+ /**
22
+ * The runtime's zone inventory, read once. `Intl.supportedValuesOf` is not
23
+ * available in every engine, so a missing inventory degrades to `UTC` rather
24
+ * than offering a list that cannot resolve.
25
+ *
26
+ * The inventory is deliberately NOT truncated. A capped list silently omits
27
+ * whole regions, and because a `<select>` whose value matches no option falls
28
+ * back to its first option, an omitted zone would make the control show — and
29
+ * then save — a different zone than the rule actually stores.
30
+ */
31
+ let inventoryCache: readonly string[] | undefined
32
+
33
+ function zoneInventory(): readonly string[] {
34
+ if (inventoryCache !== undefined) return inventoryCache
35
+ let inventory: readonly string[] = []
36
+ try {
37
+ inventory = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
38
+ } catch {
39
+ inventory = []
40
+ }
41
+ inventoryCache = inventory.length > 0 ? inventory : ['UTC']
42
+ return inventoryCache
43
+ }
44
+
45
+ /**
46
+ * Zones offered by the picker: the Host zone first (the default, and the entry
47
+ * a rule with no stored zone follows), then the runtime's own inventory.
48
+ *
49
+ * The Host entry is always present, even before the Host snapshot reports a
50
+ * zone: it is the entry that stores no zone, so dropping it would leave a rule
51
+ * unable to express "follow the Host" and would let the control silently
52
+ * display an unrelated zone.
53
+ *
54
+ * A stored zone the inventory does not list (a rule written on another machine
55
+ * or under a different ICU build) is appended, so opening the editor can never
56
+ * silently rewrite the rule's zone.
57
+ * @param hostTimeZone - zone the Host reported for this deployment, when known.
58
+ * @param storedTimeZone - zone the edited rule already stores, when any.
59
+ * @returns the de-duplicated choice list.
60
+ */
61
+ export function zoneChoices(hostTimeZone: string | undefined, storedTimeZone?: string): ZoneChoice[] {
62
+ const host = hostTimeZone !== undefined && isValidTimeZone(hostTimeZone) ? hostTimeZone : undefined
63
+ const choices: ZoneChoice[] = []
64
+ const seen = new Set<string>()
65
+ choices.push({
66
+ id: '',
67
+ label: host === undefined
68
+ ? t('detail.schedule.timeZoneHostUnknown')
69
+ : t('detail.schedule.timeZoneHost', { timeZone: host }),
70
+ })
71
+ if (host !== undefined) seen.add(host)
72
+ for (const zone of zoneInventory()) {
73
+ if (seen.has(zone) || !isValidTimeZone(zone)) continue
74
+ seen.add(zone)
75
+ choices.push({ id: zone, label: zone })
76
+ }
77
+ if (storedTimeZone !== undefined && !seen.has(storedTimeZone) && isValidTimeZone(storedTimeZone)) {
78
+ choices.push({ id: storedTimeZone, label: storedTimeZone })
79
+ }
80
+ return choices
81
+ }
82
+
83
+ /** Compact duration parts, largest unit first, with at most two units kept. */
84
+ function durationParts(ms: number): string {
85
+ const total = Math.max(0, Math.floor(ms / 1000))
86
+ const days = Math.floor(total / 86400)
87
+ const hours = Math.floor((total % 86400) / 3600)
88
+ const minutes = Math.floor((total % 3600) / 60)
89
+ const seconds = total % 60
90
+ const parts: string[] = []
91
+ if (days > 0) parts.push(t('detail.schedule.duration.days', { count: String(days) }))
92
+ if (hours > 0) parts.push(t('detail.schedule.duration.hours', { count: String(hours) }))
93
+ if (minutes > 0) parts.push(t('detail.schedule.duration.minutes', { count: String(minutes) }))
94
+ if (parts.length < 2 && (seconds > 0 || parts.length === 0)) {
95
+ parts.push(t('detail.schedule.duration.seconds', { count: String(seconds) }))
96
+ }
97
+ return parts.slice(0, 2).join(' ')
98
+ }
99
+
100
+ /**
101
+ * The relative half of a next-run label: how far away the instant is, or how
102
+ * far past it the board is running. `now` is passed in rather than sampled so
103
+ * callers can share one clock reading across a row, and tests stay
104
+ * deterministic.
105
+ * @param targetMs - the instant being described.
106
+ * @param now - the reference instant.
107
+ * @returns localized relative wording.
108
+ */
109
+ export function relativeTimeLabel(targetMs: number, now: number): string {
110
+ const delta = targetMs - now
111
+ if (delta < 0) return t('detail.schedule.nextRunOverdue', { duration: durationParts(-delta) })
112
+ return `${durationParts(delta)}`
113
+ }
114
+
115
+ /**
116
+ * The full next-run label: the absolute wall clock in the schedule's own zone,
117
+ * plus the relative distance in parentheses. Showing both matters because a
118
+ * rule's zone can differ from the reader's, so the wall clock alone is
119
+ * ambiguous and the distance alone loses the actual moment.
120
+ * @param targetMs - the instant being described.
121
+ * @param timeZone - zone to render the absolute half in.
122
+ * @param formatAbsolute - formatter for the absolute half (keeps Intl caching in the caller).
123
+ * @param now - the reference instant.
124
+ * @param translate - translate function, bound so this module stays dependency-light.
125
+ * @returns the combined label.
126
+ */
127
+ export function nextRunLabel(
128
+ targetMs: number,
129
+ timeZone: string | undefined,
130
+ formatAbsolute: (ms: number, timeZone?: string) => string,
131
+ now: number,
132
+ ): string {
133
+ const absolute = formatAbsolute(targetMs, timeZone)
134
+ return `${absolute} (${relativeTimeLabel(targetMs, now)})`
135
+ }
@@ -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
+ }