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

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.
@@ -16,33 +16,6 @@
16
16
  */
17
17
  import { subscribeBodyInvalidations } from './body-mutations.ts'
18
18
 
19
- /**
20
- * One trailing action button seated at the entry row's right edge (e.g. a
21
- * refresh command or a collapse chevron). Rows with actions switch from the
22
- * classic single-button structure to a container with a main button plus the
23
- * action buttons (nested interactive elements are invalid HTML); rows without
24
- * actions keep the classic structure untouched.
25
- */
26
- export interface SidebarEntryAction {
27
- /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
28
- id: string
29
- /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
30
- icon: string
31
- /**
32
- * Optional icon while the row is inactive. Setting it makes the action a
33
- * state mirror (a collapse chevron): the core swaps the icon and stamps
34
- * aria-expanded on every active-state change. Requires the active bridge.
35
- */
36
- inactiveIcon?: string
37
- /** Localized accessible label (aria-label + title); receives the active state. */
38
- label(active: boolean): string
39
- /**
40
- * Click handler; receives the button so the caller can drive transient
41
- * affordances (a refresh cooldown) without a re-render.
42
- */
43
- onClick(button: HTMLButtonElement): void
44
- }
45
-
46
19
  /** Per-package configuration for one sidebar entry row. */
47
20
  export interface SidebarEntryOptions {
48
21
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -74,12 +47,6 @@ export interface SidebarEntryOptions {
74
47
  refresh?: { subscribe(listener: () => void): () => void }
75
48
  /** Click action (open/toggle the owning panel). */
76
49
  onToggle(): void
77
- /**
78
- * Optional trailing action buttons at the row's right edge. Requires the
79
- * package CSS to seat entryMain / entryAction; empty keeps the classic
80
- * single-button row.
81
- */
82
- actions?: readonly SidebarEntryAction[]
83
50
  /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
84
51
  position: 'before' | 'after'
85
52
  /**
@@ -118,11 +85,9 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
118
85
  }
119
86
 
120
87
  /** Build the entry row (detached; inserted once the shell is up). */
121
- function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
122
- const actions = options.actions ?? []
123
- const composite = actions.length > 0
124
- const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
125
- if (!composite) (entry as HTMLButtonElement).type = 'button'
88
+ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void } {
89
+ const entry = document.createElement('button')
90
+ entry.type = 'button'
126
91
  entry.setAttribute(options.rowAttribute, '')
127
92
  if (options.plugin !== undefined) {
128
93
  entry.setAttribute('data-dsh-plugin', options.plugin)
@@ -134,62 +99,15 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyL
134
99
  const iconSpan = document.createElement('span')
135
100
  iconSpan.className = options.css['entryIcon'] ?? ''
136
101
  iconSpan.innerHTML = options.icon
137
- // The main hit area: the classic row IS the button; with trailing actions
138
- // the row becomes a container and the main area its own button, so the row
139
- // never nests interactive elements.
140
- const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
141
- if (composite) {
142
- main.type = 'button'
143
- main.className = options.css['entryMain'] ?? ''
144
- main.append(iconSpan, labelSpan)
145
- entry.append(main)
146
- } else {
147
- entry.append(iconSpan, labelSpan)
148
- }
149
- const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
150
- for (const action of actions) {
151
- const button = document.createElement('button')
152
- button.type = 'button'
153
- button.className = options.css['entryAction'] ?? ''
154
- button.setAttribute('data-dsh-entry-action', action.id)
155
- button.innerHTML = action.icon
156
- button.addEventListener('click', () => { action.onClick(button) })
157
- entry.append(button)
158
- actionButtons.push({ action, button })
159
- }
160
- let open = false
161
- const applyActions = (): void => {
162
- for (const { action, button } of actionButtons) {
163
- button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
164
- const text = action.label(open)
165
- button.setAttribute('aria-label', text)
166
- button.setAttribute('title', text)
167
- if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
168
- }
169
- }
102
+ entry.append(iconSpan, labelSpan)
170
103
  const applyLabel = (): void => {
171
- main.setAttribute('aria-label', options.label())
172
- if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
104
+ entry.setAttribute('aria-label', options.label())
105
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
173
106
  labelSpan.textContent = options.label()
174
- applyActions()
175
107
  }
176
108
  applyLabel()
177
- main.addEventListener('click', options.onToggle)
178
- if (composite) {
179
- // The old row was clickable edge to edge; keep the padding between the
180
- // main area and the action buttons a toggle target as well.
181
- entry.addEventListener('click', (event) => {
182
- if (event.target === entry) options.onToggle()
183
- })
184
- }
185
- return {
186
- entry,
187
- applyLabel,
188
- setOpen: (next: boolean) => {
189
- open = next
190
- applyActions()
191
- },
192
- }
109
+ entry.addEventListener('click', options.onToggle)
110
+ return { entry, applyLabel }
193
111
  }
194
112
 
195
113
  /** Re-insert the entry after the New Session row (before the browser region). */
@@ -231,7 +149,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
231
149
  if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
232
150
  return () => {}
233
151
  }
234
- const { entry, applyLabel, setOpen } = createEntry(options)
152
+ const { entry, applyLabel } = createEntry(options)
235
153
  let root: HTMLElement | undefined
236
154
  let placed = false
237
155
  let unsubscribeRefresh: (() => void) | undefined
@@ -299,10 +217,8 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
299
217
  // row permanently highlighted — delete the attribute instead.
300
218
  const unsubscribeActive = options.active === undefined ? undefined : (() => {
301
219
  const syncActive = (): void => {
302
- const open = options.active!.isOpen()
303
- if (open) entry.dataset.active = 'true'
220
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
304
221
  else delete entry.dataset.active
305
- setOpen(open)
306
222
  }
307
223
  const unsubscribe = options.active.subscribe(syncActive)
308
224
  syncActive()
@@ -19,8 +19,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
19
19
  /** Stable data attribute identifying the injected entry row. */
20
20
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
21
21
 
22
- /** Inline icon normalized to the shell's 18px navigation glyph size. */
23
- const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
22
+ /** Inline icon normalized to the shell's 16px navigation glyph size. */
23
+ const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
24
24
 
25
25
  /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
26
26
  export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
@@ -42,18 +42,22 @@ export interface TaskBoardTransport {
42
42
  parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
43
43
  }
44
44
 
45
- /** The sessions face the controller needs for navigation awareness. */
45
+ /**
46
+ * The sessions face the controller needs for navigation awareness. The Client
47
+ * Session Controller carries no global selection since 0.1.6-alpha.2, so the
48
+ * wiring resolves the main-view Session once and exposes it as current().
49
+ */
46
50
  export interface SessionsControllerFace {
47
- list: {
48
- getSnapshot(): { current: string | undefined }
49
- subscribe(fn: () => void): () => void
50
- }
51
- /** Select a session as current (navigates the conversation view). */
51
+ /** The Session the main view currently shows, when any. */
52
+ current(): string | undefined
53
+ /** Navigate the main view to a Session. */
52
54
  open(id: string): void
55
+ /** Subscribe to catalog changes; selection ownership rides the same snapshot. */
56
+ subscribe(fn: () => void): () => void
53
57
  }
54
58
 
55
59
  function currentOf(sessions: SessionsControllerFace | undefined): string | undefined {
56
- return sessions?.list.getSnapshot().current
60
+ return sessions?.current()
57
61
  }
58
62
 
59
63
  /** Controller dependencies (all swappable in tests). */
@@ -194,7 +198,7 @@ export class BoardController {
194
198
  this.notify()
195
199
  }) : undefined
196
200
  if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
197
- this.disposers.push(this.deps.sessions.list.subscribe(() => {
201
+ this.disposers.push(this.deps.sessions.subscribe(() => {
198
202
  this.onSessionsChanged()
199
203
  }))
200
204
  this.notify()
package/src/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or
package/src/index.ts CHANGED
@@ -6,11 +6,10 @@
6
6
  * a same-origin asynchronous view over that service.
7
7
  */
8
8
 
9
- import type { Context } from '@deepseek-ai/cordis'
9
+ import type { Context, Volatile } from '@deepseek-ai/cordis'
10
10
  import type {} from '@deepseek-ai/dsh-agent'
11
11
  import type {} from '@deepseek-ai/dsh-commands'
12
- import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
13
- import z from 'schemastery'
12
+ import z from '@deepseek-ai/schemastery'
14
13
  import type {} from '@deepseek-ai/dsh-system-prompt'
15
14
  import type {} from '@deepseek-ai/dsh-api-gateway'
16
15
  import type {} from '@deepseek-ai/dsh-workspace'
@@ -35,25 +34,30 @@ export const inject = ['systemPrompt', 'typertGateway', 'workspaceRegistry', 'we
35
34
  export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
36
35
 
37
36
  /**
38
- * Settings namespace of the board's announcement capability — the section the
39
- * web settings surface edits. Spelled here rather than imported: the browser
40
- * half spells the same value and must not depend on a Host package.
37
+ * Plugin config, validated by the same-named schemastery schema.
38
+ *
39
+ * This schema IS the board's settings page: the 0.1.7 Host serves one
40
+ * configuration form per profile entry from the entry's own Config, so the
41
+ * three fields the browser card edits are marked volatile — the Loader commits
42
+ * an edit into the running fiber's references without remounting the row (no
43
+ * settings document of the plugin's own exists any more).
41
44
  */
42
- export const TASK_BOARD_SETTINGS_NAMESPACE = 'task-board' as SettingsNamespace
43
-
44
- /** Plugin config, validated by the same-named schemastery schema. */
45
45
  export interface Config {
46
46
  /**
47
47
  * When true (default), a system-prompt section announces the board to every
48
48
  * agent. Set false to keep the board silent in prompts; agents then learn
49
49
  * about it only when the user mentions it.
50
50
  */
51
- announceToAgent?: boolean
51
+ announceToAgent?: Volatile<boolean>
52
52
  /** Master switch for the plugin (browser half + host announcement). */
53
- enabled?: boolean
53
+ enabled?: Volatile<boolean>
54
54
  /** Prevent idle system sleep while sessions run or schedules are armed. */
55
- preventIdleSleep?: boolean
56
- /** Canonical reverse-proxy Host authorities admitted with a server-side token. */
55
+ preventIdleSleep?: Volatile<boolean>
56
+ /**
57
+ * Canonical reverse-proxy Host authorities admitted with a server-side
58
+ * token. Deployment-level, so it stays an ordinary field: editing it reloads
59
+ * the row, which re-registers the routes with the new proxy access.
60
+ */
57
61
  trustedProxyHosts?: string[]
58
62
  /** Environment variable whose value the authenticated proxy injects upstream. */
59
63
  proxyTokenEnv?: string
@@ -65,15 +69,54 @@ export interface Config {
65
69
  sessionDefaultPermission?: TaskPermission
66
70
  }
67
71
 
68
- export const Config: z<Config> = z.object({
69
- announceToAgent: z.boolean().default(false),
70
- enabled: z.boolean().default(true),
71
- preventIdleSleep: z.boolean().default(false),
72
+ /**
73
+ * The schema is left to inference rather than annotated with `z<Config>`: a
74
+ * volatile field's parsed output is a `Volatile` reference while its accepted
75
+ * input stays the plain value, so the two sides no longer share one shape and
76
+ * the annotation would reject the schema the Host must be given.
77
+ */
78
+ export const Config = z.object({
79
+ announceToAgent: z.boolean().default(false).volatile(),
80
+ enabled: z.boolean().default(true).volatile(),
81
+ preventIdleSleep: z.boolean().default(false).volatile(),
72
82
  trustedProxyHosts: z.array(z.string()).default([]),
73
83
  proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
74
84
  sessionDefaultPermission: z.union(TASK_PERMISSIONS).default(DEFAULT_SESSION_PERMISSION),
75
85
  })
76
86
 
87
+ declare module '@deepseek-ai/cordis' {
88
+ interface Events {
89
+ /**
90
+ * Volatile config values were committed into the running fiber without a
91
+ * remount; dispatched to the owning fiber only. Spelled here because the
92
+ * Loader package is not a dependency of this plugin, with the Loader's own
93
+ * shape so the two declarations merge when a Host program carries both.
94
+ * @param paths - changed config paths as key arrays; every value is committed before dispatch.
95
+ * @mode emit
96
+ */
97
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Read one config field's current value.
103
+ *
104
+ * The Loader hands schema-volatile fields as stable references it commits in
105
+ * place, so a live value must be read at use time rather than captured when
106
+ * the plugin activates; a plain value (a programmatic mount, or a field the
107
+ * schema does not mark volatile) is returned as it stands.
108
+ * @param field - the config field as the Loader handed it.
109
+ * @param fallback - value to use when the field is absent.
110
+ * @returns the effective field value.
111
+ */
112
+ export function readConfigField<T>(field: Volatile<T> | T | undefined, fallback: T): T {
113
+ if (field === undefined) return fallback
114
+ if (typeof field === 'object' && field !== null && typeof (field as { get?: unknown }).get === 'function') {
115
+ return (field as Volatile<T>).get() as T
116
+ }
117
+ return field as T
118
+ }
119
+
77
120
  /** Resolve proxy access without ever placing the token value in plugin config. */
78
121
  export function resolveProxyAccess(config: Config | undefined, env: NodeJS.ProcessEnv = process.env): { trustedProxyHosts: string[]; proxyToken?: string } {
79
122
  const trustedProxyHosts = config?.trustedProxyHosts ?? []
@@ -107,16 +150,28 @@ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
107
150
  }
108
151
 
109
152
  /**
110
- * Register the board's announcement section, gated on the composition entry's
111
- * `announceToAgent` (and the live settings value once the web settings
112
- * surface is served). The section is re-registered whenever the source
113
- * changes, so a settings edit takes effect without a restart.
153
+ * Activate the board's host half: ledger, routes, cron scheduler, power
154
+ * inhibitor, and the model-facing announcement.
155
+ *
156
+ * The effective settings are the config the Host hands this row. The three
157
+ * fields the browser card edits are schema-volatile, so the Loader commits an
158
+ * edit into the running fiber's references instead of remounting the row —
159
+ * `sync` therefore reads them at use time and follows
160
+ * `loader/volatile-update`, the event the Loader emits once it has committed
161
+ * them. Every other field reloads the row, which re-runs this activation.
114
162
  * @param ctx - the plugin context (systemPrompt injected).
115
163
  * @param config - resolved plugin config (schema defaults applied by the loader).
116
164
  */
117
165
  export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
118
166
 
119
167
  function applyImpl(ctx: Context, config?: Config): void {
168
+ /** Current master switch (the browser half reads the same field from its own form). */
169
+ const enabled = (): boolean => readConfigField(config?.enabled, true)
170
+ /** Current announcement flag. */
171
+ const announceToAgent = (): boolean => readConfigField(config?.announceToAgent, DEFAULT_ANNOUNCE)
172
+ /** Current idle-sleep protection flag. */
173
+ const preventIdleSleep = (): boolean => readConfigField(config?.preventIdleSleep, false)
174
+
120
175
  const host = new TaskBoardHostService(ctx.typertGateway, {
121
176
  workspaceRegistry: ctx.workspaceRegistry,
122
177
  sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
@@ -128,7 +183,9 @@ function applyImpl(ctx: Context, config?: Config): void {
128
183
  },
129
184
  },
130
185
  })
131
- host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false)
186
+ // Configuration before start(): a disabled row must not take the first
187
+ // scheduler tick, which would roll schedules the board is not running.
188
+ host.setConfiguration(enabled(), preventIdleSleep())
132
189
  host.start()
133
190
  ctx.effect(() => {
134
191
  const disposers: Array<() => void> = []
@@ -151,24 +208,27 @@ function applyImpl(ctx: Context, config?: Config): void {
151
208
  host.dispose()
152
209
  }
153
210
  }, 'task-board: host ledger, scheduler, and routes')
154
- // The live source the announcement reads: the settings section once the web
155
- // settings surface is served, the composition entry otherwise
156
- // (installSection swaps it when the namespace registers).
157
- let current: () => Config = () => config ?? {}
211
+
158
212
  let disposeSection: (() => void) | undefined
213
+ let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean } | undefined
159
214
 
160
- // Register (or drop) the announcement to match the current source. The
161
- // section is kept under one disposer: re-registering first tears the old
162
- // one down so a duplicate-name registration never throws.
215
+ // Apply the current values to the host service and the announcement. A
216
+ // commit that changes nothing visible is a no-op, so following a coarse
217
+ // invalidation cannot churn the system-prompt registry. The section is kept
218
+ // under one disposer: re-registering first tears the old one down so a
219
+ // duplicate-name registration never throws.
163
220
  const sync = (): void => {
221
+ const next = { enabled: enabled(), announceToAgent: announceToAgent(), preventIdleSleep: preventIdleSleep() }
222
+ if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep) {
223
+ return
224
+ }
225
+ applied = next
226
+ host.setConfiguration(next.enabled, next.preventIdleSleep)
164
227
  if (disposeSection !== undefined) {
165
228
  disposeSection()
166
229
  disposeSection = undefined
167
230
  }
168
- const active = current().enabled ?? true
169
- host.setConfiguration(active, current().preventIdleSleep ?? false)
170
- if (!active) return
171
- if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return
231
+ if (!next.enabled || !next.announceToAgent) return
172
232
  disposeSection = ctx.systemPrompt.section({
173
233
  name: 'plugin:task-board',
174
234
  order: SECTION_ORDER,
@@ -176,24 +236,9 @@ function applyImpl(ctx: Context, config?: Config): void {
176
236
  })
177
237
  }
178
238
 
179
- ctx.inject(['settings'], (settingsCtx) => {
180
- try {
181
- if (typeof settingsCtx.settings?.installSection === 'function') {
182
- settingsCtx.settings.installSection(ctx, TASK_BOARD_SETTINGS_NAMESPACE, Config, config ?? {}, {
183
- setSource: (source) => { current = source },
184
- onChange: sync,
185
- })
186
- } else if (typeof settingsCtx.settings?.register === 'function') {
187
- const scope = settingsCtx.settings.register(TASK_BOARD_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
188
- current = () => scope?.get?.() ?? (config ?? {})
189
- scope?.watch?.(() => { sync() })
190
- }
191
- } catch {
192
- // Defensive fallback against settings registration differences
193
- }
194
- })
195
-
196
- // Initial registration from the composition entry (covers deployments with
197
- // no settings service, whose installSection never fires its hooks).
239
+ // A settings edit of a volatile field is committed into the references this
240
+ // fiber already holds, with no remount and no second call to apply: the
241
+ // commit event is what makes the edit take effect without a restart.
242
+ ctx.on('loader/volatile-update', () => { sync() })
198
243
  sync()
199
244
  }