dsh-workspace-plus 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -8,13 +8,13 @@
8
8
  * plugin is first needed, so every side effect here — the injected stylesheet
9
9
  * included — happens at materialization time, never at script-parse time.
10
10
  *
11
- * The feature — workspace directories, as a DIALOG rather than a page.
11
+ * The workspace+ feature — workspace directories, as a DIALOG rather than a page.
12
12
  * A DSH workspace keeps its one root directory; this registers N more project
13
13
  * folders against it, each with a short label, and the host injects that table
14
14
  * into the agent's context on every request — so "改 backend" in conversation
15
15
  * resolves to an absolute path instead of a guess.
16
16
  *
17
- * Five seats cooperate:
17
+ * Five workspace+ seats cooperate:
18
18
  *
19
19
  * `settings.section` the `workspace+` settings page. It owns one preference:
20
20
  * whether the feature runs for the default (first-use)
@@ -24,18 +24,24 @@
24
24
  * `shell.overlay` the dialog itself, the same frame-wide layer the
25
25
  * shell's own workspace Rename/Delete dialogs use (the
26
26
  * Modal portals to <body>, so the entry owns only the
27
- * open state, not the stack position). Its path field is
28
- * a control rather than an input: clicking it opens the
29
- * shell's own directory picker through
30
- * `ctx.uiWorkspace.pickDirectory()`.
27
+ * open state, not the stack position). It reads as one
28
+ * column of bordered field rows — the workspace it is
29
+ * locked to, the 「目录」 list, an add row that IS the
30
+ * shell's own directory picker, and a footer split
31
+ * secondary-left / primary-right. There is no path input
32
+ * to type into: `ctx.uiWorkspace.pickDirectory()` is the
33
+ * only way a directory gets in, and the label/note row
34
+ * only appears once one has been chosen.
31
35
  *
32
36
  * workspace row button the primary trigger. The shell's workspace row menu is
33
37
  * hardcoded to Rename/Delete with no plugin slot, so the
34
38
  * trigger is a button this half appends to the row's
35
39
  * action cluster, located through shell-emitted
36
40
  * `data-row-key="workspace:<id>"` anchors and kept in
37
- * place by a MutationObserver. Rows the host reports as
38
- * disabled get no button.
41
+ * place by a MutationObserver. Every real workspace row
42
+ * carries it; a workspace whose switch is off only draws
43
+ * it dimmed, because the dialog is where its labels are
44
+ * read and the switch itself lives on the settings page.
39
45
  *
40
46
  * `sidebar.panellist` the fallback trigger, registered only while no row
41
47
  * button could be installed (a shell whose row DOM
@@ -52,6 +58,19 @@
52
58
  * absolute path, so the model receives the path even when
53
59
  * the injected table is not in context.
54
60
  *
61
+ * The 通知 (notice) sub-feature — ported from the dsh-notice plugin, which now
62
+ * ships as the `dsh-workspace-plus/notice` loader row of this same bundle — adds
63
+ * two more seats here, described in detail at its section below:
64
+ *
65
+ * `settings.section` the 通知 settings page (same id, label and order the
66
+ * standalone plugin used), writing through the same
67
+ * `/dsh-notice/api` routes. When the notice row is off
68
+ * the host answers 404 and the page explains the state.
69
+ *
70
+ * the pending poll the last centimetre of the click-to-open path: toast
71
+ * click → `dsh-notice:` protocol → host route → pending
72
+ * intent → THIS half → `ctx.uiWorkspace.openSession()`.
73
+ *
55
74
  * Only the shell's frozen platform module table may be `require`d here (react,
56
75
  * react-dom, react/jsx-runtime, @deepseek-ai/cordis,
57
76
  * @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
@@ -113,6 +132,506 @@ window.__ModuleLoader__.load({
113
132
  /** Host JSON endpoint, resolved against the page so it survives any mount path. */
114
133
  const API_PATH = 'workspaceplus/api'
115
134
 
135
+ // -----------------------------------------------------------------------
136
+ // The 通知 (notice) sub-feature — client side
137
+ //
138
+ // The notice host half (`lib/notice/index.js`, the `dsh-workspace-plus/notice`
139
+ // loader row) owns the Windows transport and three HTTP routes. This half
140
+ // contributes the two browser-side pieces the dsh-notice plugin used to ship
141
+ // in its own client bundle:
142
+ //
143
+ // the 通知 settings page — same slot (`settings.section`), same id, same
144
+ // order, writing through the SAME routes as before (`/dsh-notice/api`).
145
+ // When the notice row is disabled the host answers 404, the page renders
146
+ // that state, and nothing else in this bundle is affected.
147
+ //
148
+ // the pending-open poll — the last centimetre of the click-to-open path:
149
+ // toast click → protocol → host route → pending intent → THIS half →
150
+ // ctx.uiWorkspace.openSession(). The poll is unconditionally cheap (one
151
+ // 404-tolerant GET every 4 s) and is what makes clicking a notification
152
+ // open the conversation; it stays on even when the settings page cannot
153
+ // load, because a notification may outlive a row toggle.
154
+ //
155
+ // Route paths and the settings-page identity are kept byte-identical to
156
+ // dsh-notice 0.2.1, so an in-place upgrade preserves the protocol scheme,
157
+ // the registry command and every stored override.
158
+ // -----------------------------------------------------------------------
159
+
160
+ /** Settings section identity of the 通知 page (unchanged from dsh-notice). */
161
+ const NOTICE_SETTINGS_SECTION_ID = 'notice'
162
+ const NOTICE_SETTINGS_SECTION_LABEL = '通知'
163
+ const NOTICE_SETTINGS_SECTION_ORDER = 26
164
+
165
+ /** Host routes the notice half talks to; must match lib/notice/index.js. */
166
+ const NOTICE_API_PATH = '/dsh-notice/api'
167
+ const NOTICE_PENDING_PATH = '/dsh-notice/api/pending'
168
+
169
+ /** Backstop poll interval, and the retry interval after a failed poll. */
170
+ const NOTICE_POLL_INTERVAL_MS = 4000
171
+ const NOTICE_POLL_RETRY_MS = 2000
172
+
173
+ /** Stylesheet identity for the 通知 settings page (one tag, rewritten on reload). */
174
+ const NOTICE_STYLE_ID = 'dsh-workspace-plus/notice.css'
175
+
176
+ /** Prefix for every class the notice page injects, so it can never collide. */
177
+ const NOTICE_CSS = `
178
+ .dsh-notice-section { display: flex; flex-direction: column; gap: 14px; }
179
+ .dsh-notice-group { display: flex; flex-direction: column; gap: 10px; }
180
+ .dsh-notice-group-title { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 600; letter-spacing: .04em; }
181
+ .dsh-notice-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
182
+ .dsh-notice-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
183
+ .dsh-notice-title { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; }
184
+ .dsh-notice-desc { color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.5; }
185
+ .dsh-notice-switch { position: relative; flex: none; width: 38px; height: 22px; margin-top: 2px; padding: 0; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; background: var(--dsw-alias-bg-layer-2); transition: background .15s ease, border-color .15s ease; }
186
+ .dsh-notice-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
187
+ .dsh-notice-switch:disabled { cursor: default; opacity: .5; }
188
+ .dsh-notice-knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-bg-base); transition: transform .15s ease; }
189
+ .dsh-notice-switch[aria-checked="true"] .dsh-notice-knob { transform: translateX(16px); }
190
+ .dsh-notice-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
191
+ .dsh-notice-button { cursor: pointer; padding: 5px 12px; font-size: 13px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
192
+ .dsh-notice-button:hover:not(:disabled) { border-color: var(--dsw-alias-border-l1); }
193
+ .dsh-notice-button:disabled { cursor: default; opacity: .5; }
194
+ .dsh-notice-status { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.6; }
195
+ .dsh-notice-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
196
+ .dsh-notice-error { color: var(--dsw-alias-state-error-primary); }
197
+ .dsh-notice-ok { color: var(--dsw-alias-state-success-primary); }
198
+ `
199
+
200
+ /** Install the notice stylesheet, exactly one tag per document (rewritten on reload). */
201
+ function ensureNoticeStyles() {
202
+ if (typeof document === 'undefined') return
203
+ const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(NOTICE_STYLE_ID) + ']')
204
+ if (existing !== null) {
205
+ existing.textContent = NOTICE_CSS
206
+ return
207
+ }
208
+ const tag = document.createElement('style')
209
+ tag.dataset.plugin = 'dsh-workspace-plus'
210
+ tag.dataset.pluginCss = NOTICE_STYLE_ID
211
+ tag.textContent = NOTICE_CSS
212
+ document.head.appendChild(tag)
213
+ }
214
+
215
+ /**
216
+ * Fetch the notice host's snapshot.
217
+ *
218
+ * @param origin - the origin this page was served from, reported once so the
219
+ * host can learn its own listen port and register the protocol against it.
220
+ * @param signal - caller cancellation.
221
+ * @returns the parsed payload.
222
+ */
223
+ async function fetchNoticeSnapshot(origin, signal) {
224
+ const query = origin === null ? '' : `?origin=${encodeURIComponent(origin)}`
225
+ const response = await fetch(`${NOTICE_API_PATH}${query}`, { cache: 'no-store', signal })
226
+ if (!response.ok) throw new Error(`host responded ${response.status}`)
227
+ return await response.json()
228
+ }
229
+
230
+ /**
231
+ * Post one notice control operation.
232
+ *
233
+ * @param body - operation payload.
234
+ * @returns the parsed payload.
235
+ */
236
+ async function postNoticeOperation(body) {
237
+ const response = await fetch(NOTICE_API_PATH, {
238
+ method: 'POST',
239
+ cache: 'no-store',
240
+ headers: { 'content-type': 'application/json' },
241
+ body: JSON.stringify(body),
242
+ })
243
+ if (!response.ok) throw new Error(`host responded ${response.status}`)
244
+ return await response.json()
245
+ }
246
+
247
+ /**
248
+ * One labelled switch row of the 通知 page.
249
+ *
250
+ * @param props - `{ title, description, checked, disabled, onChange }`.
251
+ * @returns the row element.
252
+ */
253
+ function NoticeSwitchRow(props) {
254
+ return React.createElement(
255
+ 'div',
256
+ { className: 'dsh-notice-row' },
257
+ React.createElement(
258
+ 'div',
259
+ { className: 'dsh-notice-text' },
260
+ React.createElement('div', { className: 'dsh-notice-title' }, props.title),
261
+ props.description ? React.createElement('div', { className: 'dsh-notice-desc' }, props.description) : null,
262
+ ),
263
+ React.createElement(
264
+ 'button',
265
+ {
266
+ type: 'button',
267
+ role: 'switch',
268
+ 'aria-checked': props.checked === true,
269
+ 'aria-label': props.title,
270
+ disabled: props.disabled === true,
271
+ className: 'dsh-notice-switch',
272
+ onClick: () => props.onChange(props.checked !== true),
273
+ },
274
+ React.createElement('span', { className: 'dsh-notice-knob' }),
275
+ ),
276
+ )
277
+ }
278
+
279
+ /**
280
+ * The 通知 settings page (the same page the dsh-notice plugin registered,
281
+ * now riding in this bundle). Every switch writes immediately; the host
282
+ * answers with a fresh snapshot, so the page never predicts what was
283
+ * accepted. A 404 — the notice row being disabled — renders as an
284
+ * explanation instead of an error, because that state is a choice, not a
285
+ * breakage.
286
+ *
287
+ * @returns the settings section element.
288
+ */
289
+ function NoticeSettings() {
290
+ const [snapshot, setSnapshot] = React.useState(null)
291
+ const [status, setStatus] = React.useState({ kind: 'loading', message: '' })
292
+ const [busy, setBusy] = React.useState(false)
293
+
294
+ React.useEffect(() => {
295
+ let cancelled = false
296
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
297
+ ;(async () => {
298
+ try {
299
+ const payload = await fetchNoticeSnapshot(null, controller?.signal)
300
+ if (cancelled) return
301
+ setSnapshot(payload)
302
+ setStatus({ kind: 'ready', message: '' })
303
+ } catch (error) {
304
+ if (cancelled) return
305
+ setStatus({ kind: 'error', message: String((error && error.message) || error) })
306
+ }
307
+ })()
308
+ return () => {
309
+ cancelled = true
310
+ controller?.abort()
311
+ }
312
+ }, [])
313
+
314
+ const send = React.useCallback(async (body) => {
315
+ setBusy(true)
316
+ try {
317
+ const payload = await postNoticeOperation(body)
318
+ // A rejected operation answers with a message and no snapshot (an
319
+ // unknown op, a body that is not JSON). Adopting that as state would
320
+ // blank the page, so it only updates the status line.
321
+ if (payload !== null && typeof payload === 'object' && payload.settings !== undefined) {
322
+ setSnapshot(payload)
323
+ }
324
+ setStatus({
325
+ kind: payload !== null && payload.ok === true ? 'ready' : 'error',
326
+ message: String(payload?.message ?? ''),
327
+ })
328
+ } catch (error) {
329
+ setStatus({ kind: 'error', message: String((error && error.message) || error) })
330
+ } finally {
331
+ setBusy(false)
332
+ }
333
+ }, [])
334
+
335
+ if (snapshot === null) {
336
+ const message = status.kind === 'error' ? status.message : ''
337
+ // `host responded 404` is the notice ROW being switched off on the
338
+ // plugin page — a deliberate state, worded as one rather than as a
339
+ // failure. Any other message is a genuine transport error.
340
+ if (status.kind === 'error' && message.includes('404')) {
341
+ return React.createElement(
342
+ 'div',
343
+ { className: 'dsh-notice-section' },
344
+ React.createElement(
345
+ 'div',
346
+ { className: 'dsh-notice-desc' },
347
+ '通知子功能当前未启用:在侧栏「插件」页的 dsh-workspace-plus 卡片里打开 notice 这一行即可开启。',
348
+ ),
349
+ )
350
+ }
351
+ return React.createElement(
352
+ 'div',
353
+ { className: 'dsh-notice-section' },
354
+ React.createElement(
355
+ 'div',
356
+ { className: 'dsh-notice-desc' },
357
+ status.kind === 'error' ? `读取设置失败:${message}` : '正在读取设置…',
358
+ ),
359
+ )
360
+ }
361
+
362
+ const settings = snapshot.settings ?? {}
363
+ const kinds = Array.isArray(snapshot.kinds) ? snapshot.kinds : []
364
+ const toast = snapshot.toast ?? {}
365
+ const protocol = snapshot.protocol ?? {}
366
+ const disabled = busy || settings.enabled !== true
367
+
368
+ const kindRows = kinds.map((kind) =>
369
+ React.createElement(NoticeSwitchRow, {
370
+ key: kind.id,
371
+ title: kind.label,
372
+ checked: kind.enabled === true,
373
+ disabled,
374
+ onChange: (next) => void send({ op: 'patch', patch: { kinds: { [kind.id]: next } } }),
375
+ }),
376
+ )
377
+
378
+ const transportLine = (() => {
379
+ if (snapshot.platformSupported !== true) return '当前平台不是 Windows,通知不会显示。'
380
+ // The daemon starts with the first notification, so "not running yet" is
381
+ // the ordinary state right after loading the plugin, not a fault.
382
+ if (toast.running !== true && toast.started !== true) {
383
+ return 'toast 守护进程尚未启动,将在第一条通知到来时自动启动。'
384
+ }
385
+ if (toast.running !== true) return 'toast 守护进程未运行,下一条通知会自动重新启动它。'
386
+ return `toast 守护进程运行中,以 ${
387
+ toast.appIdConfigured === true ? '指定' : '自动'
388
+ } AUMID 发言:${toast.appId ?? '(未知)'}`
389
+ })()
390
+
391
+ const protocolLine = (() => {
392
+ if (protocol.registered === true) return `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
393
+ if (settings.registerProtocol === false) return '已关闭协议注册:点击通知只会把 Harness 窗口切到前台。'
394
+ return `尚未注册 ${protocol.scheme ?? 'dsh-notice'}:,点击通知只会把 Harness 窗口切到前台。`
395
+ })()
396
+
397
+ return React.createElement(
398
+ 'div',
399
+ { className: 'dsh-notice-section' },
400
+ React.createElement(
401
+ 'div',
402
+ { className: 'dsh-notice-group' },
403
+ React.createElement(NoticeSwitchRow, {
404
+ title: '启用桌面通知',
405
+ description: '对话需要你时,弹出 Windows 原生通知;点击通知可打开对应的对话。',
406
+ checked: settings.enabled === true,
407
+ disabled: busy,
408
+ onChange: (next) => void send({ op: 'patch', patch: { enabled: next } }),
409
+ }),
410
+ ),
411
+ React.createElement(
412
+ 'div',
413
+ { className: 'dsh-notice-group' },
414
+ React.createElement('div', { className: 'dsh-notice-group-title' }, '通知类型'),
415
+ ...kindRows,
416
+ ),
417
+ React.createElement(
418
+ 'div',
419
+ { className: 'dsh-notice-group' },
420
+ React.createElement('div', { className: 'dsh-notice-group-title' }, '行为'),
421
+ React.createElement(NoticeSwitchRow, {
422
+ title: '只通知主对话',
423
+ description: '关闭后,子代理与团队成员的每一轮结束也会通知(通常非常密集)。',
424
+ checked: settings.scope === 'roots',
425
+ disabled,
426
+ onChange: (next) => void send({ op: 'patch', patch: { scope: next ? 'roots' : 'all' } }),
427
+ }),
428
+ React.createElement(NoticeSwitchRow, {
429
+ title: '通知声音',
430
+ description: '关闭后通知静音出现,仍然进入 Windows 通知中心。',
431
+ checked: settings.sound === true,
432
+ disabled,
433
+ onChange: (next) => void send({ op: 'patch', patch: { sound: next } }),
434
+ }),
435
+ React.createElement(NoticeSwitchRow, {
436
+ title: '点击时把 Harness 窗口切到前台',
437
+ description: '关闭后只切换对话,不改变窗口的前后顺序。',
438
+ checked: settings.focusOnClick === true,
439
+ disabled,
440
+ onChange: (next) => void send({ op: 'patch', patch: { focusOnClick: next } }),
441
+ }),
442
+ React.createElement(NoticeSwitchRow, {
443
+ title: '注册 dsh-notice: 协议',
444
+ description:
445
+ '在当前用户注册表下注册协议,让点击通知能定位到具体对话。关闭则退化为只切前台,不写注册表。',
446
+ checked: settings.registerProtocol === true,
447
+ disabled,
448
+ onChange: (next) => void send({ op: 'patch', patch: { registerProtocol: next } }),
449
+ }),
450
+ React.createElement(
451
+ 'div',
452
+ { className: 'dsh-notice-row' },
453
+ React.createElement(
454
+ 'div',
455
+ { className: 'dsh-notice-text' },
456
+ React.createElement('div', { className: 'dsh-notice-title' }, '通知显示时长'),
457
+ React.createElement(
458
+ 'div',
459
+ { className: 'dsh-notice-desc' },
460
+ settings.duration === 'long' ? '当前:长(约 25 秒)' : '当前:短(约 5 秒)',
461
+ ),
462
+ ),
463
+ React.createElement(
464
+ 'button',
465
+ {
466
+ type: 'button',
467
+ className: 'dsh-notice-button',
468
+ disabled,
469
+ onClick: () => void send({ op: 'patch', patch: { duration: settings.duration === 'long' ? 'short' : 'long' } }),
470
+ },
471
+ settings.duration === 'long' ? '改为短' : '改为长',
472
+ ),
473
+ ),
474
+ ),
475
+ React.createElement(
476
+ 'div',
477
+ { className: 'dsh-notice-actions' },
478
+ React.createElement(
479
+ 'button',
480
+ {
481
+ type: 'button',
482
+ className: 'dsh-notice-button',
483
+ disabled: busy,
484
+ onClick: () => void send({ op: 'test' }),
485
+ },
486
+ '发送测试通知',
487
+ ),
488
+ React.createElement(
489
+ 'button',
490
+ {
491
+ type: 'button',
492
+ className: 'dsh-notice-button',
493
+ disabled: busy || Object.keys(snapshot.overrides ?? {}).length === 0,
494
+ onClick: () => void send({ op: 'clear' }),
495
+ },
496
+ '恢复默认',
497
+ ),
498
+ ),
499
+ React.createElement(
500
+ 'div',
501
+ { className: 'dsh-notice-status' },
502
+ React.createElement('div', null, transportLine),
503
+ React.createElement('div', null, protocolLine),
504
+ toast.lastError
505
+ ? React.createElement('div', { className: 'dsh-notice-mono dsh-notice-error' }, String(toast.lastError))
506
+ : null,
507
+ status.message
508
+ ? React.createElement(
509
+ 'div',
510
+ { className: status.kind === 'error' ? 'dsh-notice-error' : 'dsh-notice-ok' },
511
+ status.message,
512
+ )
513
+ : null,
514
+ ),
515
+ )
516
+ }
517
+
518
+ /**
519
+ * Poll the notice host for a pending "open this conversation" intent.
520
+ *
521
+ * A click on a notification focuses the desktop window; the focus event
522
+ * triggers an immediate poll, so the conversation usually opens on the same
523
+ * frame the window appears. The timer is only the backstop for a click that
524
+ * arrives while the window already has focus. A 404 (notice row disabled)
525
+ * and a network failure are both silent: this poll must never become
526
+ * noticeable, and it must never take the workspace+ features down.
527
+ *
528
+ * @param ctx - the plugin's client context.
529
+ * @returns a disposer stopping every timer, listener and request in flight.
530
+ */
531
+ function startNoticePendingPoll(ctx) {
532
+ let stopped = false
533
+ let timer = null
534
+ let inFlight = false
535
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
536
+
537
+ const openSession = (sessionId) => {
538
+ if (typeof sessionId !== 'string' || sessionId === '') return
539
+ let workspace = null
540
+ try {
541
+ workspace = ctx.get('uiWorkspace')
542
+ } catch {
543
+ return
544
+ }
545
+ if (workspace === undefined || workspace === null || typeof workspace.openSession !== 'function') return
546
+ try {
547
+ workspace.openSession(sessionId)
548
+ } catch {
549
+ // An archived or unknown conversation cannot be selected; the click
550
+ // still brought the window forward, which is the useful half.
551
+ }
552
+ }
553
+
554
+ const schedule = (delay) => {
555
+ if (stopped) return
556
+ if (timer !== null) clearTimeout(timer)
557
+ timer = setTimeout(() => void tick(), delay)
558
+ }
559
+
560
+ const tick = async () => {
561
+ if (stopped || inFlight) return
562
+ inFlight = true
563
+ let delay = NOTICE_POLL_INTERVAL_MS
564
+ try {
565
+ const response = await fetch(`${NOTICE_PENDING_PATH}?_=${Date.now()}`, {
566
+ cache: 'no-store',
567
+ signal: controller?.signal,
568
+ })
569
+ if (response.ok) {
570
+ const payload = await response.json()
571
+ if (payload !== null && typeof payload === 'object' && typeof payload.sessionId === 'string') {
572
+ openSession(payload.sessionId)
573
+ }
574
+ } else {
575
+ delay = NOTICE_POLL_RETRY_MS
576
+ }
577
+ } catch {
578
+ delay = NOTICE_POLL_RETRY_MS
579
+ } finally {
580
+ inFlight = false
581
+ }
582
+ schedule(delay)
583
+ }
584
+
585
+ // A click on a notification focuses the desktop window, and that focus
586
+ // event is what makes the conversation open immediately rather than up to
587
+ // one poll interval later.
588
+ const onFocus = () => schedule(0)
589
+ window.addEventListener('focus', onFocus)
590
+ document.addEventListener('visibilitychange', onFocus)
591
+
592
+ // Report the origin once, so the host learns the port it is listening on
593
+ // and can point the protocol registration at it. The settings page loads
594
+ // lazily, so this one early GET is also the only thing that wakes the
595
+ // host's protocol sync when the user never opens 设置 → 通知.
596
+ void fetchNoticeSnapshot(window.location.origin, controller?.signal).catch(() => {})
597
+ void tick()
598
+
599
+ return () => {
600
+ stopped = true
601
+ if (timer !== null) clearTimeout(timer)
602
+ window.removeEventListener('focus', onFocus)
603
+ document.removeEventListener('visibilitychange', onFocus)
604
+ controller?.abort()
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Run one notice registration, reporting a failure instead of throwing it.
610
+ *
611
+ * The shell treats a non-ACTIVE client entry as a fatal boot error, so an
612
+ * exception here would take the whole application down with it. The failure
613
+ * is still surfaced — through the shell's logger — but the fiber stays
614
+ * active and the workspace+ features keep working.
615
+ *
616
+ * @param ctx - the plugin's client context.
617
+ * @param subject - what was being registered, named in the message.
618
+ * @param setup - the registration body.
619
+ * @returns nothing.
620
+ */
621
+ function registerNotice(ctx, subject, setup) {
622
+ try {
623
+ setup()
624
+ } catch (error) {
625
+ const message = String((error && error.message) || error)
626
+ try {
627
+ ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${subject}注册失败,已跳过:${message}`)
628
+ } catch {
629
+ // A logger that cannot log must not turn a skipped registration back
630
+ // into the boot failure this boundary exists to prevent.
631
+ }
632
+ }
633
+ }
634
+
116
635
  /** Identity of the injected stylesheet, used to keep exactly one copy. */
117
636
  const STYLE_ID = 'dsh-workspace-plus/panel.css'
118
637
 
@@ -128,9 +647,19 @@ window.__ModuleLoader__.load({
128
647
  /** Class of the button this half appends to a workspace row. */
129
648
  const ROW_BUTTON_CLASS = 'dsh-workspace-plus-rowbtn'
130
649
 
650
+ /**
651
+ * Class marking that same button on a workspace the settings page switched
652
+ * off. The button stays — the dialog is the only place a workspace's labels
653
+ * can be read — but it draws dimmed and says why in its tooltip.
654
+ */
655
+ const ROW_BUTTON_OFF_CLASS = 'dsh-workspace-plus-rowbtn-off'
656
+
131
657
  /** Label of the injected row button. */
132
658
  const ROW_BUTTON_LABEL = '工作区目录设置'
133
659
 
660
+ /** Label of that button where workspace+ is switched off for the workspace. */
661
+ const ROW_BUTTON_LABEL_OFF = '工作区目录设置(这个工作区已在 workspace+ 里停用)'
662
+
134
663
  /**
135
664
  * The shell's own folder-open artwork, copied verbatim from
136
665
  * `@deepseek-ai/dsh-client-ui-primitives` (`IconFolderOpenArtwork`). It is
@@ -172,6 +701,25 @@ window.__ModuleLoader__.load({
172
701
  'M6.80132 2.14853C7.70663 1.80917 8.70422 1.80919 9.60952 2.14859L14.1296 3.84317V7.11961C14.1296 11.6089 ' +
173
702
  '10.7615 13.5975 8.20543 14.5779C5.64931 13.5975 2.28052 11.6089 2.28052 7.11961V3.84317L6.80132 2.14853Z'
174
703
 
704
+ /**
705
+ * Two more official glyphs, same provenance as the folder artwork above and
706
+ * for the same reason: the dialog's row actions and its add row should read as
707
+ * the shell's own icons without depending on an export name.
708
+ *
709
+ * CLOSE_ARTWORK `IconCloseOutline16` (`ic_ds_close_outline_16`) — the row's
710
+ * remove control, the same cross the shell puts in a dialog's
711
+ * top-right corner.
712
+ * PLUS_ARTWORK `IconPlusOutline16` (`ic_ds_plus_outline_16`) — the add row.
713
+ * It is deliberately not a folder: the row's folder glyphs
714
+ * belong to registered directories, and a second folder would
715
+ * make the add row read as one of them.
716
+ */
717
+ const CLOSE_ARTWORK = [
718
+ 'M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z',
719
+ 'M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z',
720
+ ]
721
+ const PLUS_ARTWORK = ['M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z']
722
+
175
723
  /**
176
724
  * How long the fallback entry waits before deciding that no workspace-row
177
725
  * button can exist, so a healthy shell never flashes it.
@@ -202,36 +750,54 @@ window.__ModuleLoader__.load({
202
750
  .dsh-workspace-plus-settings-ok{color:var(--dsw-alias-state-success-primary);margin-top:8px;font-size:12px;line-height:18px}
203
751
  .dsh-workspace-plus-rowbtn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;flex:none;cursor:pointer;border-radius:var(--dsw-radius-xs,4px);background:0 0;color:var(--dsw-alias-label-tertiary)}
204
752
  .dsh-workspace-plus-rowbtn:hover{color:var(--dsw-alias-label-primary)}
753
+ .dsh-workspace-plus-rowbtn-off{opacity:.55}
754
+ .dsh-workspace-plus-rowbtn-off:hover{opacity:1;color:var(--dsw-alias-label-primary)}
205
755
  .dsh-workspace-plus-rowbtn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
206
- .dsh-workspace-plus-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.6}
207
- .dsh-workspace-plus-dialog-card.dsh-workspace-plus-dialog-card{width:min(760px,100%)}
208
- .dsh-workspace-plus-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
209
- .dsh-workspace-plus-label{color:var(--dsw-alias-label-secondary);flex:none}
210
- .dsh-workspace-plus-locked{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
211
- .dsh-workspace-plus-select,.dsh-workspace-plus-input{box-sizing:border-box;font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,8px);padding:6px 9px;min-width:0}
212
- .dsh-workspace-plus-select{max-width:100%;flex:1 1 240px}
213
- .dsh-workspace-plus-input:focus-visible,.dsh-workspace-plus-select:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px}
214
- .dsh-workspace-plus-input-label{flex:0 1 180px}
215
- .dsh-workspace-plus-input-note{flex:1 1 220px}
216
- .dsh-workspace-plus-path{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 10px;font:inherit;text-align:left;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,8px);cursor:pointer}
217
- .dsh-workspace-plus-path:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
218
- .dsh-workspace-plus-path:disabled{cursor:not-allowed;color:var(--dsw-alias-label-tertiary)}
219
- .dsh-workspace-plus-path:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px}
220
- .dsh-workspace-plus-path-glyph{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-secondary)}
221
- .dsh-workspace-plus-path-empty{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
222
- .dsh-workspace-plus-list{box-sizing:border-box;width:100%;max-height:34vh;overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-md,8px)}
223
- .dsh-workspace-plus-row{display:flex;align-items:center;gap:12px;padding:9px 12px;background:var(--dsw-alias-bg-layer-1);border-top:1px solid var(--dsw-alias-border-l1)}
224
- .dsh-workspace-plus-row:first-child{border-top:none}
225
- .dsh-workspace-plus-row-tag{flex:none;min-width:76px;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-brand-primary);font-weight:600;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
226
- .dsh-workspace-plus-row-path{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
227
- .dsh-workspace-plus-row-note{flex:0 1 auto;color:var(--dsw-alias-label-secondary);max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
756
+ .dsh-workspace-plus-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
757
+ .dsh-workspace-plus-dialog-card.dsh-workspace-plus-dialog-card{width:min(640px,100%)}
758
+ .dsh-workspace-plus-field{box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
759
+ .dsh-workspace-plus-field-glyph{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-tertiary)}
760
+ .dsh-workspace-plus-field-title{flex:none;min-width:0;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
761
+ .dsh-workspace-plus-field-root{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px;color:var(--dsw-alias-label-tertiary)}
762
+ .dsh-workspace-plus-section{margin:0 2px -6px;color:var(--dsw-alias-label-secondary)}
763
+ .dsh-workspace-plus-list{box-sizing:border-box;width:100%;max-height:34vh;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
764
+ .dsh-workspace-plus-row,.dsh-workspace-plus-field-row{box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border-top:1px solid var(--dsw-alias-border-l1)}
765
+ .dsh-workspace-plus-row:first-child,.dsh-workspace-plus-field-row:first-child{border-top:none}
766
+ .dsh-workspace-plus-row-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
767
+ .dsh-workspace-plus-row-path{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px;color:var(--dsw-alias-label-tertiary)}
768
+ .dsh-workspace-plus-row-note{flex:0 1 auto;max-width:16ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--dsw-alias-label-secondary)}
228
769
  .dsh-workspace-plus-row-outside{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-state-warn-primary)}
229
770
  .dsh-workspace-plus-row-outside:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px;border-radius:var(--dsw-radius-xs,4px)}
230
- .dsh-workspace-plus-form{display:flex;flex-direction:column;gap:8px;width:100%}
231
- .dsh-workspace-plus-form-row{display:flex;align-items:center;gap:10px;width:100%}
771
+ .dsh-workspace-plus-remove{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:var(--dsw-radius-sm,8px);background:0 0;color:var(--dsw-alias-label-tertiary);cursor:pointer}
772
+ .dsh-workspace-plus-remove:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
773
+ .dsh-workspace-plus-remove:disabled{cursor:not-allowed;opacity:.4}
774
+ .dsh-workspace-plus-remove:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
775
+ .dsh-workspace-plus-add{width:100%;font:inherit;text-align:left;color:var(--dsw-alias-label-secondary);background:0 0;border-right:none;border-bottom:none;border-left:none;cursor:pointer}
776
+ .dsh-workspace-plus-add:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
777
+ .dsh-workspace-plus-add:disabled{cursor:not-allowed;color:var(--dsw-alias-label-tertiary)}
778
+ .dsh-workspace-plus-add:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
779
+ .dsh-workspace-plus-add-text{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
780
+ .dsh-workspace-plus-add-path{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px}
781
+ .dsh-workspace-plus-select{min-width:0;flex:1 1 auto;font:inherit;color:var(--dsw-alias-label-primary);background:0 0;border:none;padding:0}
782
+ /* The light theme resolves bg-layer-1/2/3 to the same white, so a borderless
783
+ input filled with a layer token disappears inside its group there. The
784
+ interactive fills are the one neutral surface pair that reads in both themes:
785
+ 6%/10% of the label colour in light, 8%/14% of it in dark. */
786
+ .dsh-workspace-plus-input{box-sizing:border-box;min-width:0;height:34px;padding:0 10px;font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border:none;border-radius:var(--dsw-radius-sm,8px)}
787
+ .dsh-workspace-plus-input:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}
788
+ .dsh-workspace-plus-input:disabled{background:0 0;color:var(--dsw-alias-label-tertiary)}
789
+ .dsh-workspace-plus-select:focus-visible,.dsh-workspace-plus-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-1px}
790
+ .dsh-workspace-plus-input-label{flex:0 1 180px}
791
+ .dsh-workspace-plus-input-note{flex:1 1 auto}
232
792
  .dsh-workspace-plus-submit{flex:none}
233
- .dsh-workspace-plus-empty{padding:14px 12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
234
- .dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary);font-size:13px}
793
+ .dsh-workspace-plus-empty{color:var(--dsw-alias-label-secondary)}
794
+ .dsh-workspace-plus-notice{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
795
+ .dsh-workspace-plus-notice-error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,var(--dsw-alias-bg-layer-1))}
796
+ .dsh-workspace-plus-notice-warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,var(--dsw-alias-bg-layer-1))}
797
+ .dsh-workspace-plus-status{font-size:14px;line-height:22px}
798
+ .dsh-workspace-plus-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%}
799
+ .dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary)}
800
+ .dsh-workspace-plus-off{color:var(--dsw-alias-state-warn-primary)}
235
801
  .dsh-workspace-plus-error{color:var(--dsw-alias-state-error-primary)}
236
802
  .dsh-workspace-plus-ok{color:var(--dsw-alias-state-success-primary)}
237
803
  .dsh-workspace-plus-mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);word-break:break-all}
@@ -263,20 +829,25 @@ window.__ModuleLoader__.load({
263
829
 
264
830
  ensureStyles()
265
831
 
832
+ // The notice page's stylesheet follows the same one-tag rule; calling it in
833
+ // the factory body (like the panel sheet above) means the 通知 page needs no
834
+ // `<style>` element of its own and reloads stay duplicate-free.
835
+ ensureNoticeStyles()
836
+
266
837
  // -----------------------------------------------------------------------
267
838
  // The workspace-directory feature
268
839
  // -----------------------------------------------------------------------
269
840
 
270
841
  /**
271
- * The folder-open artwork as an element, so the fallback sidebar entry, the
272
- * hint panel, and the dialog's directory picker all draw the same icon the
273
- * shell uses on its workspace row.
842
+ * One glyph built from inlined official artwork. Entries are either a bare
843
+ * path string or `{ d, opacity }`, so the folder's translucent underlay and
844
+ * the flat single-path icons share one renderer.
274
845
  *
275
- * @param props - optional free-form class, and the click handler when the
276
- * glyph is itself a control.
846
+ * @param props - the artwork to draw, its size, an optional class, and the
847
+ * click handler when the glyph is itself a control.
277
848
  * @returns the icon element.
278
849
  */
279
- function FolderGlyph({ size = 16, className = 'dsh-workspace-plus-glyph', onClick }) {
850
+ function ArtworkGlyph({ artwork, size = 16, className = 'dsh-workspace-plus-glyph', onClick }) {
280
851
  return React.createElement(
281
852
  'svg',
282
853
  {
@@ -290,17 +861,42 @@ window.__ModuleLoader__.load({
290
861
  focusable: 'false',
291
862
  ...(onClick === undefined ? {} : { onClick }),
292
863
  },
293
- FOLDER_ARTWORK.map((path, index) =>
294
- React.createElement('path', {
864
+ artwork.map((entry, index) => {
865
+ const path = typeof entry === 'string' ? { d: entry } : entry
866
+ return React.createElement('path', {
295
867
  key: index,
296
868
  d: path.d,
297
869
  fill: 'currentColor',
298
870
  ...(path.opacity === undefined ? {} : { opacity: path.opacity }),
299
- }),
300
- ),
871
+ })
872
+ }),
301
873
  )
302
874
  }
303
875
 
876
+ /**
877
+ * The folder-open artwork as an element, so the fallback sidebar entry, the
878
+ * hint panel, and the dialog's directory rows all draw the same icon the
879
+ * shell uses on its workspace row.
880
+ *
881
+ * @param props - optional size, free-form class, and the click handler when
882
+ * the glyph is itself a control.
883
+ * @returns the icon element.
884
+ */
885
+ function FolderGlyph({ size = 16, className, onClick }) {
886
+ return React.createElement(ArtworkGlyph, { artwork: FOLDER_ARTWORK, size, className, onClick })
887
+ }
888
+
889
+ /** The row's remove control: the shell's cross. */
890
+ function CloseGlyph({ size = 16 }) {
891
+ return React.createElement(ArtworkGlyph, { artwork: CLOSE_ARTWORK, size })
892
+ }
893
+
894
+ /** The add row's leading glyph: the shell's plus. */
895
+ function PlusGlyph({ size = 16 }) {
896
+ return React.createElement(ArtworkGlyph, { artwork: PLUS_ARTWORK, size })
897
+ }
898
+
899
+
304
900
  /**
305
901
  * The directory glyph, shared by the fallback sidebar entry and the hint
306
902
  * panel. It draws the same official artwork as the injected row button, so
@@ -511,12 +1107,18 @@ window.__ModuleLoader__.load({
511
1107
  }
512
1108
 
513
1109
  /**
514
- * Whether the feature runs for one workspace id. A row whose workspace is not
515
- * in the snapshot yet keeps its entry point: hiding it on a guess would remove
516
- * the UI from a perfectly ordinary workspace.
1110
+ * Whether workspace+ runs for one workspace id.
1111
+ *
1112
+ * This is the host's own switch (`settings.workspaceEnabled`, with the
1113
+ * default Workspace falling back to `defaultWorkspaceSetting`); the host
1114
+ * gates prompt-context injection and the `workspace_dirs` tool on it. On the
1115
+ * browser side it only chooses how the row button is presented — normal or
1116
+ * switched-off — because the button itself is present everywhere. A
1117
+ * workspace the snapshot does not know yet counts as enabled: guessing
1118
+ * "off" would mislabel a perfectly ordinary row.
517
1119
  *
518
1120
  * @param workspaceId - the id taken from the row's `data-row-key`.
519
- * @returns true when the row should carry the directory button.
1121
+ * @returns true when workspace+ is on for that workspace.
520
1122
  */
521
1123
  function isEnabledWorkspace(workspaceId) {
522
1124
  const workspace = hostState.workspaces.find((candidate) => candidate.id === workspaceId)
@@ -548,6 +1150,23 @@ window.__ModuleLoader__.load({
548
1150
  */
549
1151
  const SOURCE_NAME = 'workspace-plus'
550
1152
 
1153
+ /**
1154
+ * Where this group sits in the `@` menu.
1155
+ *
1156
+ * The pipeline sorts the roster for one trigger by `InputTriggerSource.order`
1157
+ * — lower first, `?? 0` when absent, and `Array.prototype.sort` is stable, so
1158
+ * every source that omits the field ties at 0 and keeps registration order.
1159
+ * That is exactly why this plugin used to land last: it is materialized after
1160
+ * the shell's own plugins, and the product's `@` groups are `reference` (0)
1161
+ * and `subagent` (0), with `cordis` at 1. Declaring a value below zero is the
1162
+ * only way an add-on can precede them, and `-10` leaves the product room for a
1163
+ * group or two ahead of 0 without this one having to be re-tuned.
1164
+ *
1165
+ * The labels are the ones this workspace is actually navigated by, so they
1166
+ * belong at the top of the menu rather than under the file tree.
1167
+ */
1168
+ const SOURCE_ORDER = -10
1169
+
551
1170
  /** Section heading of the group's rows, standing in for the pipeline's group title. */
552
1171
  const SOURCE_SECTION = '工作区目录'
553
1172
 
@@ -642,6 +1261,8 @@ window.__ModuleLoader__.load({
642
1261
  return {
643
1262
  trigger: '@',
644
1263
  name: SOURCE_NAME,
1264
+ // Sorting key of the `@` roster; see SOURCE_ORDER for why it is negative.
1265
+ order: SOURCE_ORDER,
645
1266
  // The pipeline's group title would be an untranslated key; each row
646
1267
  // carries its own section heading instead.
647
1268
  showGroupTitle: false,
@@ -875,7 +1496,13 @@ window.__ModuleLoader__.load({
875
1496
  }
876
1497
 
877
1498
  /**
878
- * One registered directory row.
1499
+ * One registered directory row: glyph, label, absolute path, optional note,
1500
+ * the outside-the-root badge, and the remove control.
1501
+ *
1502
+ * The remove control is the shell's own cross rather than a labelled button:
1503
+ * every row carries one, and a word repeated down the list is what made the
1504
+ * earlier table read as a form instead of a list. Its accessible name still
1505
+ * says exactly what it removes.
879
1506
  *
880
1507
  * @param props - the row's directory, its workspace root, and the remove handler.
881
1508
  * @returns the row element.
@@ -885,14 +1512,28 @@ window.__ModuleLoader__.load({
885
1512
  return React.createElement(
886
1513
  'div',
887
1514
  { className: 'dsh-workspace-plus-row' },
888
- React.createElement('span', { className: 'dsh-workspace-plus-row-tag' }, dir.label),
1515
+ React.createElement(
1516
+ 'span',
1517
+ { className: 'dsh-workspace-plus-field-glyph' },
1518
+ React.createElement(FolderGlyph, { size: 16 }),
1519
+ ),
1520
+ React.createElement('span', { className: 'dsh-workspace-plus-row-tag', title: dir.label }, dir.label),
889
1521
  React.createElement('span', { className: 'dsh-workspace-plus-row-path', title: dir.path }, dir.path),
890
- dir.note === undefined ? null : React.createElement('span', { className: 'dsh-workspace-plus-row-note' }, dir.note),
1522
+ dir.note === undefined
1523
+ ? null
1524
+ : React.createElement('span', { className: 'dsh-workspace-plus-row-note', title: dir.note }, dir.note),
891
1525
  outside ? React.createElement(OutsideBadge) : null,
892
1526
  React.createElement(
893
- Button,
894
- { variant: 'outline', size: 'sm', disabled, onClick: () => onRemove(dir.label) },
895
- '删除',
1527
+ 'button',
1528
+ {
1529
+ type: 'button',
1530
+ className: 'dsh-workspace-plus-remove',
1531
+ disabled,
1532
+ 'aria-label': '移除 ' + dir.label,
1533
+ title: '移除 ' + dir.label,
1534
+ onClick: () => onRemove(dir.label),
1535
+ },
1536
+ React.createElement(CloseGlyph, { size: 16 }),
896
1537
  ),
897
1538
  )
898
1539
  }
@@ -1053,44 +1694,181 @@ window.__ModuleLoader__.load({
1053
1694
  }
1054
1695
  }
1055
1696
 
1056
- const toolbar = React.createElement(
1057
- 'div',
1058
- { className: 'dsh-workspace-plus-toolbar' },
1059
- React.createElement('span', { className: 'dsh-workspace-plus-label' }, '工作区'),
1060
- state.locked
1061
- ? React.createElement('span', { className: 'dsh-workspace-plus-locked' }, selected?.title || selected?.path || '')
1697
+ /**
1698
+ * The dialog's blocks, in reading order. Every one of them is a bordered
1699
+ * surface with the row grid inside, so the dialog reads as one column of
1700
+ * like things rather than a toolbar, a table and a form stacked on top of
1701
+ * each other.
1702
+ */
1703
+ const identity =
1704
+ selected === null
1705
+ ? null
1062
1706
  : React.createElement(
1063
- 'select',
1064
- {
1065
- className: 'dsh-workspace-plus-select',
1066
- value: state.selected ?? '',
1067
- disabled: state.workspaces.length === 0,
1068
- 'aria-label': '选择工作区',
1069
- onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
1070
- },
1071
- state.workspaces.length === 0
1072
- ? React.createElement('option', { value: '' }, '(没有可用工作区)')
1073
- : state.workspaces.map((workspace) =>
1074
- React.createElement(
1075
- 'option',
1076
- { key: workspace.id, value: workspace.path },
1077
- (workspace.title || workspace.path) + ' — ' + workspace.path,
1078
- ),
1707
+ 'div',
1708
+ { className: 'dsh-workspace-plus-field' },
1709
+ React.createElement(
1710
+ 'span',
1711
+ { className: 'dsh-workspace-plus-field-glyph' },
1712
+ React.createElement(FolderGlyph, { size: 16 }),
1713
+ ),
1714
+ state.locked
1715
+ ? React.createElement(
1716
+ 'span',
1717
+ { className: 'dsh-workspace-plus-field-title', title: selected.title || selected.path },
1718
+ selected.title || selected.path,
1719
+ )
1720
+ : React.createElement(
1721
+ 'select',
1722
+ {
1723
+ className: 'dsh-workspace-plus-select',
1724
+ value: state.selected ?? '',
1725
+ disabled: state.workspaces.length === 0,
1726
+ 'aria-label': '选择工作区',
1727
+ onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
1728
+ },
1729
+ state.workspaces.length === 0
1730
+ ? React.createElement('option', { value: '' }, '(没有可用工作区)')
1731
+ : state.workspaces.map((workspace) =>
1732
+ React.createElement(
1733
+ 'option',
1734
+ { key: workspace.id, value: workspace.path },
1735
+ (workspace.title || workspace.path) + ' — ' + workspace.path,
1736
+ ),
1737
+ ),
1079
1738
  ),
1739
+ // The trigger names the workspace, so the locked row also says which
1740
+ // root its labels resolve against.
1741
+ state.locked
1742
+ ? React.createElement(
1743
+ 'span',
1744
+ { className: 'dsh-workspace-plus-field-root', title: selected.path },
1745
+ selected.path,
1746
+ )
1747
+ : null,
1748
+ )
1749
+
1750
+ const sectionLabel = React.createElement('div', { className: 'dsh-workspace-plus-section' }, '目录')
1751
+
1752
+ const listBlock = (...children) =>
1753
+ React.createElement('div', { className: 'dsh-workspace-plus-list' }, ...children)
1754
+
1755
+ const emptyRow = (text) =>
1756
+ React.createElement('div', { className: 'dsh-workspace-plus-field-row dsh-workspace-plus-empty' }, text)
1757
+
1758
+ const noticeBlock = (variant, ...children) =>
1759
+ React.createElement(
1760
+ 'div',
1761
+ {
1762
+ className:
1763
+ 'dsh-workspace-plus-notice' +
1764
+ (variant === '' || variant === undefined ? '' : ' dsh-workspace-plus-notice-' + variant),
1765
+ },
1766
+ ...children,
1767
+ )
1768
+
1769
+ /**
1770
+ * The add group. Its row IS the picker — there is no path to type into — and
1771
+ * the label/note row appears only once a directory has been chosen, so an
1772
+ * idle dialog is a list plus one row, not a form with two empty fields.
1773
+ */
1774
+ const addGroup = React.createElement(
1775
+ 'div',
1776
+ { className: 'dsh-workspace-plus-list' },
1777
+ React.createElement(
1778
+ 'button',
1779
+ {
1780
+ type: 'button',
1781
+ className: 'dsh-workspace-plus-field-row dsh-workspace-plus-add',
1782
+ disabled: selected === null || state.busy || picking || !pickerAvailable,
1783
+ 'aria-label': '选择目录',
1784
+ title: pickerAvailable
1785
+ ? '打开目录选择器'
1786
+ : '当前页面没有挂载 ui-workspace,目录选择器不可用;可在对话里用 workspace_dirs 登记',
1787
+ onClick: () => void browse(),
1788
+ },
1789
+ React.createElement(
1790
+ 'span',
1791
+ { className: 'dsh-workspace-plus-field-glyph' },
1792
+ React.createElement(PlusGlyph, { size: 16 }),
1793
+ ),
1794
+ React.createElement(
1795
+ 'span',
1796
+ {
1797
+ className:
1798
+ draft.path === ''
1799
+ ? 'dsh-workspace-plus-add-text'
1800
+ : 'dsh-workspace-plus-add-text dsh-workspace-plus-add-path',
1801
+ ...(draft.path === '' ? {} : { title: draft.path }),
1802
+ },
1803
+ picking ? '选择中…' : draft.path === '' ? '添加目录' : draft.path,
1804
+ ),
1805
+ ),
1806
+ draft.path === ''
1807
+ ? null
1808
+ : React.createElement(
1809
+ 'div',
1810
+ { className: 'dsh-workspace-plus-field-row dsh-workspace-plus-draft' },
1811
+ React.createElement('input', {
1812
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
1813
+ placeholder: '标签,如 backend',
1814
+ 'aria-label': '标签',
1815
+ value: draft.label,
1816
+ disabled: state.busy,
1817
+ onChange: (event) => setDraft({ ...draft, label: event.target.value }),
1818
+ }),
1819
+ React.createElement('input', {
1820
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
1821
+ placeholder: '说明(可选)',
1822
+ 'aria-label': '说明',
1823
+ value: draft.note,
1824
+ disabled: state.busy,
1825
+ onChange: (event) => setDraft({ ...draft, note: event.target.value }),
1826
+ }),
1827
+ React.createElement(
1828
+ Button,
1829
+ {
1830
+ variant: 'primary',
1831
+ className: 'dsh-workspace-plus-submit',
1832
+ disabled: state.busy || draft.path === '',
1833
+ onClick: () => void addDirectory(),
1834
+ },
1835
+ '添加',
1836
+ ),
1080
1837
  ),
1081
- state.locked
1082
- ? React.createElement('span', { className: 'dsh-workspace-plus-note dsh-workspace-plus-mono' }, selected?.path ?? '')
1083
- : null,
1084
1838
  )
1085
1839
 
1086
- const body = (() => {
1087
- if (state.status === 'loading') {
1088
- return React.createElement(
1089
- 'div',
1090
- { className: 'dsh-workspace-plus-list' },
1091
- React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '正在读取 host…'),
1092
- )
1093
- }
1840
+ // The switch that turned this workspace off is on the settings page, not
1841
+ // here, so the state is stated where the user is already looking.
1842
+ const disabledNotice =
1843
+ selected === null || selected.enabled !== false
1844
+ ? null
1845
+ : noticeBlock(
1846
+ 'warn',
1847
+ React.createElement(
1848
+ 'div',
1849
+ { className: 'dsh-workspace-plus-off' },
1850
+ '这个工作区已在 workspace+ 里停用:标签不会注入对话,workspace_dirs 工具也会拒绝操作。' +
1851
+ '到 设置 → workspace+ 打开它的开关即可恢复。',
1852
+ ),
1853
+ )
1854
+
1855
+ // The host's own sentence about what it just did — the only status copy the
1856
+ // dialog shows, and only while it is true. A failed read is not repeated
1857
+ // here: its notice above already says it, at length.
1858
+ const statusLine =
1859
+ state.status === 'error' || state.message === ''
1860
+ ? null
1861
+ : React.createElement(
1862
+ 'div',
1863
+ {
1864
+ className:
1865
+ 'dsh-workspace-plus-status ' +
1866
+ (state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok'),
1867
+ },
1868
+ state.message,
1869
+ )
1870
+
1871
+ const bodyBlocks = (() => {
1094
1872
  if (state.status === 'error') {
1095
1873
  const unauthorized = state.message.includes('401')
1096
1874
  const unrouted = state.message.includes('404')
@@ -1113,12 +1891,9 @@ window.__ModuleLoader__.load({
1113
1891
  : '这个面板通过 ' +
1114
1892
  API_PATH +
1115
1893
  ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
1116
- return React.createElement(
1117
- 'div',
1118
- { className: 'dsh-workspace-plus-list' },
1119
- React.createElement(
1120
- 'div',
1121
- { className: 'dsh-workspace-plus-empty' },
1894
+ return [
1895
+ noticeBlock(
1896
+ 'error',
1122
1897
  React.createElement('div', { className: 'dsh-workspace-plus-error' }, '无法连接 host 接口:' + state.message),
1123
1898
  React.createElement('div', { className: 'dsh-workspace-plus-note' }, diagnosis),
1124
1899
  React.createElement(
@@ -1127,128 +1902,53 @@ window.__ModuleLoader__.load({
1127
1902
  '宿主半加载之后,映射也可以在对话里用 workspace_dirs 工具管理。',
1128
1903
  ),
1129
1904
  ),
1130
- )
1905
+ ]
1131
1906
  }
1132
1907
  if (!state.registryAvailable) {
1133
- return React.createElement(
1134
- 'div',
1135
- { className: 'dsh-workspace-plus-list' },
1136
- React.createElement(
1137
- 'div',
1138
- { className: 'dsh-workspace-plus-empty' },
1139
- '宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
1908
+ return [
1909
+ noticeBlock(
1910
+ '',
1911
+ React.createElement(
1912
+ 'div',
1913
+ { className: 'dsh-workspace-plus-note' },
1914
+ '宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
1915
+ ),
1140
1916
  ),
1141
- )
1917
+ ]
1142
1918
  }
1143
- if (selected === null) {
1144
- return React.createElement(
1145
- 'div',
1146
- { className: 'dsh-workspace-plus-list' },
1147
- React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。'),
1148
- )
1919
+ if (state.status === 'loading') {
1920
+ return [sectionLabel, listBlock(emptyRow('正在读取…'))]
1149
1921
  }
1150
- return React.createElement(
1151
- 'div',
1152
- { className: 'dsh-workspace-plus-list' },
1153
- dirs.length === 0
1154
- ? React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '这个工作区还没有登记额外目录。用下面的表单添加第一个标签。')
1155
- : dirs.map((dir) =>
1156
- React.createElement(DirectoryRow, {
1157
- key: dir.label,
1158
- dir,
1159
- workspacePath: selected.path,
1160
- disabled: state.busy,
1161
- onRemove: (label) => void removeDirectory(label),
1162
- }),
1922
+ if (selected === null) {
1923
+ return [
1924
+ noticeBlock(
1925
+ '',
1926
+ React.createElement(
1927
+ 'div',
1928
+ { className: 'dsh-workspace-plus-note' },
1929
+ '还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。',
1163
1930
  ),
1931
+ ),
1932
+ ]
1933
+ }
1934
+ const rows = dirs.map((dir) =>
1935
+ React.createElement(DirectoryRow, {
1936
+ key: dir.label,
1937
+ dir,
1938
+ workspacePath: selected.path,
1939
+ disabled: state.busy,
1940
+ onRemove: (label) => void removeDirectory(label),
1941
+ }),
1164
1942
  )
1943
+ return [
1944
+ sectionLabel,
1945
+ listBlock(...(rows.length === 0 ? [emptyRow('还没有登记目录')] : rows)),
1946
+ addGroup,
1947
+ ]
1165
1948
  })()
1166
1949
 
1167
- /**
1168
- * The path field is a control, not an input: there is no typing path into
1169
- * the mapping, only the shell's directory picker. Clicking the field IS the
1170
- * picker, the chosen directory is shown in it, and the label row below
1171
- * collects the name that the conversation will use.
1172
- */
1173
- const pathField = React.createElement(
1174
- 'button',
1175
- {
1176
- type: 'button',
1177
- className: 'dsh-workspace-plus-path',
1178
- disabled: selected === null || state.busy || picking || !pickerAvailable,
1179
- 'aria-label': '选择目录',
1180
- title: pickerAvailable
1181
- ? '打开目录选择器'
1182
- : '当前页面没有挂载 ui-workspace,目录选择器不可用;可在对话里用 workspace_dirs 登记',
1183
- onClick: () => void browse(),
1184
- },
1185
- React.createElement(
1186
- 'span',
1187
- { className: 'dsh-workspace-plus-path-glyph' },
1188
- React.createElement(FolderGlyph, { size: 16 }),
1189
- ),
1190
- React.createElement(
1191
- 'span',
1192
- { className: draft.path === '' ? 'dsh-workspace-plus-path-empty' : 'dsh-workspace-plus-row-path' },
1193
- picking ? '选择中…' : draft.path === '' ? '选择目录…' : draft.path,
1194
- ),
1195
- )
1196
-
1197
- const form = React.createElement(
1198
- 'div',
1199
- { className: 'dsh-workspace-plus-form' },
1200
- pathField,
1201
- React.createElement(
1202
- 'div',
1203
- { className: 'dsh-workspace-plus-form-row' },
1204
- React.createElement('input', {
1205
- className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
1206
- placeholder: '标签,如 backend',
1207
- 'aria-label': '标签',
1208
- value: draft.label,
1209
- disabled: selected === null || state.busy,
1210
- onChange: (event) => setDraft({ ...draft, label: event.target.value }),
1211
- }),
1212
- React.createElement('input', {
1213
- className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
1214
- placeholder: '说明(可选)',
1215
- 'aria-label': '说明',
1216
- value: draft.note,
1217
- disabled: selected === null || state.busy,
1218
- onChange: (event) => setDraft({ ...draft, note: event.target.value }),
1219
- }),
1220
- React.createElement(
1221
- Button,
1222
- {
1223
- variant: 'primary',
1224
- className: 'dsh-workspace-plus-submit',
1225
- disabled: selected === null || state.busy || draft.path === '',
1226
- onClick: () => void addDirectory(),
1227
- },
1228
- '添加',
1229
- ),
1230
- ),
1231
- )
1232
-
1233
- const status = React.createElement(
1234
- 'div',
1235
- { className: 'dsh-workspace-plus-toolbar' },
1236
- state.message === ''
1237
- ? null
1238
- : React.createElement(
1239
- 'span',
1240
- { className: state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok' },
1241
- state.message,
1242
- ),
1243
- // The locked header already names the root; only the unlocked picker needs it repeated.
1244
- state.locked || selected === null
1245
- ? null
1246
- : React.createElement(
1247
- 'span',
1248
- { className: 'dsh-workspace-plus-note' },
1249
- '根目录:',
1250
- React.createElement('span', { className: 'dsh-workspace-plus-mono' }, selected.path),
1251
- ),
1950
+ const blocks = [identity, ...bodyBlocks, disabledNotice, statusLine].filter(
1951
+ (block) => block !== null && block !== undefined,
1252
1952
  )
1253
1953
 
1254
1954
  return React.createElement(
@@ -1259,21 +1959,22 @@ window.__ModuleLoader__.load({
1259
1959
  title: '工作区目录设置',
1260
1960
  closeLabel: '关闭',
1261
1961
  // The card is wider than the shell's 380px default: a row carries a
1262
- // label, an absolute path, a note and a badge, and the add form wants
1263
- // two fields plus its action on one line.
1962
+ // label, an absolute path, a note and a badge.
1264
1963
  className: 'dsh-workspace-plus-dialog-card',
1964
+ // Secondary action left, primary right — the same split the shell's own
1965
+ // product dialogs use, so the footer reads without a second look.
1265
1966
  footer: React.createElement(
1266
- React.Fragment,
1267
- null,
1967
+ 'div',
1968
+ { className: 'dsh-workspace-plus-footer' },
1268
1969
  React.createElement(
1269
1970
  Button,
1270
- { variant: 'outline', disabled: state.busy, onClick: () => void load(requestedId) },
1971
+ { variant: 'ghost', disabled: state.busy, onClick: () => void load(requestedId) },
1271
1972
  '刷新',
1272
1973
  ),
1273
1974
  React.createElement(Button, { variant: 'primary', onClick: closeWorkdirsDialog }, '完成'),
1274
1975
  ),
1275
1976
  },
1276
- React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, toolbar, status, body, form),
1977
+ React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, ...blocks),
1277
1978
  )
1278
1979
  }
1279
1980
 
@@ -1281,9 +1982,11 @@ window.__ModuleLoader__.load({
1281
1982
  * The `workspace+` settings page, registered into `settings.section`.
1282
1983
  *
1283
1984
  * It owns one preference per workspace: whether the feature runs there. That
1284
- * single switch covers all three surfaces at once — the workspace row button,
1285
- * the label table injected into that workspace's conversations, and the
1286
- * `workspace_dirs` tool.
1985
+ * single switch covers the two surfaces the feature itself drives — the label
1986
+ * table injected into that workspace's conversations and the `workspace_dirs`
1987
+ * tool. It deliberately does NOT cover the workspace row button: every row
1988
+ * keeps its management entry point, so a switched-off workspace can still be
1989
+ * inspected (and understood) instead of silently losing its only way in.
1287
1990
  *
1288
1991
  * Every workspace defaults to on except the default (first-use) one, which
1289
1992
  * belongs to the product rather than to this plugin and therefore starts off.
@@ -1467,9 +2170,9 @@ window.__ModuleLoader__.load({
1467
2170
  React.createElement(
1468
2171
  'div',
1469
2172
  { className: 'dsh-workspace-plus-settings-description' },
1470
- '按工作区启用:开关控制这个工作区的行内目录按钮、注入它对话上下文的标签表,以及 ',
2173
+ '按工作区启用:开关控制注入这个工作区对话上下文的标签表,以及 ',
1471
2174
  React.createElement('span', { className: 'dsh-workspace-plus-mono' }, 'workspace_dirs'),
1472
- ' 工具。关掉不会删除已登记的标签。',
2175
+ ' 工具。关掉不会删除已登记的标签,也不会收走工作区行上的目录按钮——那个按钮在任何工作区都能打开弹窗。',
1473
2176
  ),
1474
2177
  workspaceList,
1475
2178
  atLine,
@@ -1528,6 +2231,14 @@ window.__ModuleLoader__.load({
1528
2231
  * re-insert its own children around ours — so the position is re-asserted on
1529
2232
  * every reconcile pass rather than set once at creation.
1530
2233
  *
2234
+ * Re-assert only when the seat actually moved: `insertBefore` on a node that
2235
+ * is already exactly where it belongs still detaches and re-attaches it, and
2236
+ * a real pointer press that spans such a re-seat never becomes a click — the
2237
+ * browser drops the click because the press target was not connected when the
2238
+ * pointer was released. Reconcile runs on every DOM mutation in the frame, so
2239
+ * that detach used to land inside almost every press and the button was
2240
+ * unclickable while still looking (and hit-testing) perfectly fine.
2241
+ *
1531
2242
  * @param actions - the row's action cluster.
1532
2243
  * @param button - this plugin's button.
1533
2244
  */
@@ -1535,9 +2246,32 @@ window.__ModuleLoader__.load({
1535
2246
  const last = actions.lastElementChild
1536
2247
  if (last === button) return
1537
2248
  const anchor = actions.children.length > 1 ? last : null
2249
+ if (button.parentElement === actions && button.nextElementSibling === anchor) return
1538
2250
  actions.insertBefore(button, anchor)
1539
2251
  }
1540
2252
 
2253
+ /**
2254
+ * Paint one row button with the switch state of its workspace.
2255
+ *
2256
+ * The button exists for every workspace, so this is also the only place the
2257
+ * switched-off state is visible on the row: the marker class dims the glyph
2258
+ * and the label says why, while the click still opens the dialog — which is
2259
+ * where those labels live and where the user decides what to do next.
2260
+ *
2261
+ * Idempotent, so the reconcile pass can call it on every button it already
2262
+ * owns without touching the DOM when nothing changed.
2263
+ *
2264
+ * @param button - this plugin's button on that row.
2265
+ * @param workspaceId - the id taken from the row's `data-row-key`.
2266
+ */
2267
+ function markRowButton(button, workspaceId) {
2268
+ const enabled = isEnabledWorkspace(workspaceId)
2269
+ const label = enabled ? ROW_BUTTON_LABEL : ROW_BUTTON_LABEL_OFF
2270
+ button.classList.toggle(ROW_BUTTON_OFF_CLASS, !enabled)
2271
+ if (button.title !== label) button.title = label
2272
+ if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label)
2273
+ }
2274
+
1541
2275
  /**
1542
2276
  * Insert the directory button into every workspace row and keep it there.
1543
2277
  *
@@ -1551,9 +2285,16 @@ window.__ModuleLoader__.load({
1551
2285
  * A MutationObserver reconciles additions, removals, and position, because
1552
2286
  * React re-renders the row without knowing this node exists.
1553
2287
  *
1554
- * A workspace whose `enabled` flag is false (the default Workspace before the
1555
- * user turns its switch on) gets no button, and loses one if it is disabled
1556
- * while mounted — `refresh()` re-runs the reconcile after a settings change.
2288
+ * Every real workspace row carries the button, including one whose `enabled`
2289
+ * flag is false (the default Workspace before the user turns its switch on,
2290
+ * or a workspace the user switched off). The switch governs whether the
2291
+ * label table runs for that workspace — context injection and the
2292
+ * `workspace_dirs` tool — never whether its management UI can be reached:
2293
+ * the dialog is the only place those labels can be read, and the settings
2294
+ * page is the only place the switch can be flipped, so hiding the trigger
2295
+ * would strand both. A switched-off workspace only changes the button's
2296
+ * tooltip and draws it dimmed, and `refresh()` re-reads the state after a
2297
+ * settings change.
1557
2298
  *
1558
2299
  * @param options - the availability callback, fired when the injected count changes.
1559
2300
  * @returns `{ dispose, available, refresh }`.
@@ -1584,7 +2325,7 @@ window.__ModuleLoader__.load({
1584
2325
  const live = new Set(rows)
1585
2326
  for (const [row, button] of [...injected]) {
1586
2327
  const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1587
- const stillWanted = live.has(row) && row.isConnected && workspaceId !== '' && isEnabledWorkspace(workspaceId)
2328
+ const stillWanted = live.has(row) && row.isConnected && workspaceId !== ''
1588
2329
  if (stillWanted) continue
1589
2330
  button.remove()
1590
2331
  injected.delete(row)
@@ -1593,14 +2334,13 @@ window.__ModuleLoader__.load({
1593
2334
  const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1594
2335
  // The synthetic "ungrouped" row carries the empty key and owns no workspace.
1595
2336
  if (workspaceId === '') continue
1596
- // A switched-off workspace gets no entry point at all.
1597
- if (!isEnabledWorkspace(workspaceId)) continue
1598
2337
  const actions = row.lastElementChild
1599
2338
  if (actions === null || actions === undefined) continue
1600
2339
  const existing = injected.get(row)
1601
2340
  if (existing !== undefined && existing.isConnected) {
1602
- // Already there: only its seat may have moved.
2341
+ // Already there: only its seat and its switch state may have moved.
1603
2342
  seatRowButton(actions, existing)
2343
+ markRowButton(existing, workspaceId)
1604
2344
  continue
1605
2345
  }
1606
2346
  const button = document.createElement('button')
@@ -1609,12 +2349,35 @@ window.__ModuleLoader__.load({
1609
2349
  button.title = ROW_BUTTON_LABEL
1610
2350
  button.setAttribute('aria-label', ROW_BUTTON_LABEL)
1611
2351
  button.innerHTML = ROW_BUTTON_GLYPH
1612
- button.addEventListener('click', (event) => {
1613
- // Keep the click off the row, whose own handler toggles the group.
2352
+ /**
2353
+ * Open the dialog for the row the button currently sits on. The key is
2354
+ * read here rather than captured, because the button outlives a row
2355
+ * whose key changes.
2356
+ */
2357
+ const activate = (event) => {
2358
+ // Keep the activation off the row, whose own handler toggles the group.
1614
2359
  event.preventDefault()
1615
2360
  event.stopPropagation()
1616
- openWorkdirsDialog(workspaceId)
2361
+ const current = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
2362
+ openWorkdirsDialog(current === '' ? null : current)
2363
+ }
2364
+ button.addEventListener('click', activate)
2365
+ /**
2366
+ * A press can still lose its click when the shell's React replaces part
2367
+ * of the row mid-press (the browser then drops the click, because the
2368
+ * press target is no longer connected at release). The release itself
2369
+ * always arrives, so it opens the same dialog; a following click is a
2370
+ * no-op because the store ignores a repeated identical open.
2371
+ *
2372
+ * Touch is excluded: a scroll-drag that happens to start on this 16px
2373
+ * button delivers its release here through implicit pointer capture,
2374
+ * and opening the dialog then would be wrong. Taps still open it
2375
+ * through the click that follows.
2376
+ */
2377
+ button.addEventListener('pointerup', (event) => {
2378
+ if (event.button === 0 && event.pointerType !== 'touch') activate(event)
1617
2379
  })
2380
+ markRowButton(button, workspaceId)
1618
2381
  seatRowButton(actions, button)
1619
2382
  injected.set(row, button)
1620
2383
  }
@@ -1660,7 +2423,9 @@ window.__ModuleLoader__.load({
1660
2423
 
1661
2424
  /**
1662
2425
  * Contribute the `workspace+` settings page, the directory dialog, its
1663
- * fallback entry, and the workspace-row buttons that open it.
2426
+ * fallback entry, and the workspace-row buttons that open it — plus the two
2427
+ * client faces of the 通知 sub-feature (its settings page and the
2428
+ * pending-open poll behind notification clicks).
1664
2429
  *
1665
2430
  * Every registration goes through `ctx.slots.inject(key, callback)`: the
1666
2431
  * callback runs as soon as the shell declares that slot (immediately when it
@@ -1687,6 +2452,33 @@ window.__ModuleLoader__.load({
1687
2452
  'dsh-workspace-plus: settings section',
1688
2453
  )
1689
2454
 
2455
+ // The 通知 sub-feature. Both faces are guarded by `registerNotice`: a
2456
+ // failure is reported and skipped instead of failing the fiber, because
2457
+ // the shell's boot audit treats every non-ACTIVE entry as fatal and the
2458
+ // workspace+ features must survive a notice-side mistake. The poll runs
2459
+ // unconditionally — clicking an older notification must still open the
2460
+ // conversation even while the notice ROW is off (its host half answers
2461
+ // 404, the poll backs off quietly). The settings page lives in the same
2462
+ // slot with the id/order dsh-notice used, so it reads as the same page.
2463
+ registerNotice(ctx, '通知轮询', () => ctx.effect(() => startNoticePendingPoll(ctx), 'dsh-workspace-plus/notice: pending open poll'))
2464
+ registerNotice(ctx, '通知设置页', () =>
2465
+ ctx.effect(
2466
+ () =>
2467
+ ctx.slots.inject('settings.section', () =>
2468
+ ctx.slots.register(
2469
+ {
2470
+ name: 'settings.section',
2471
+ id: NOTICE_SETTINGS_SECTION_ID,
2472
+ order: NOTICE_SETTINGS_SECTION_ORDER,
2473
+ label: NOTICE_SETTINGS_SECTION_LABEL,
2474
+ },
2475
+ NoticeSettings,
2476
+ ),
2477
+ ),
2478
+ 'dsh-workspace-plus/notice: settings section',
2479
+ ),
2480
+ )
2481
+
1690
2482
  // The dialog lives in the frame-wide overlay: the same seat the shell's own
1691
2483
  // workspace Rename/Delete dialogs occupy.
1692
2484
  ctx.effect(
@@ -1802,9 +2594,9 @@ window.__ModuleLoader__.load({
1802
2594
  },
1803
2595
  })
1804
2596
 
1805
- // Warm the shared snapshot at boot and keep the row button in step with the
1806
- // settings: a switch flipped in the settings page adds or removes it without
1807
- // a reload.
2597
+ // Warm the shared snapshot at boot and keep every row button in step with
2598
+ // the settings: a switch flipped in the settings page re-marks its button
2599
+ // (dimmed, with the reason in its tooltip) without a reload.
1808
2600
  const offHostState = subscribeHostState(() => rowButtons.refresh())
1809
2601
  void refreshHostState()
1810
2602
 
@@ -1823,11 +2615,27 @@ window.__ModuleLoader__.load({
1823
2615
  exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID
1824
2616
  exports.SETTINGS_SECTION_LABEL = SETTINGS_SECTION_LABEL
1825
2617
  exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER
2618
+ exports.NOTICE_SETTINGS_SECTION_ID = NOTICE_SETTINGS_SECTION_ID
2619
+ exports.NOTICE_SETTINGS_SECTION_LABEL = NOTICE_SETTINGS_SECTION_LABEL
2620
+ exports.NOTICE_SETTINGS_SECTION_ORDER = NOTICE_SETTINGS_SECTION_ORDER
2621
+ exports.NOTICE_API_PATH = NOTICE_API_PATH
2622
+ exports.NOTICE_PENDING_PATH = NOTICE_PENDING_PATH
2623
+ exports.NOTICE_POLL_INTERVAL_MS = NOTICE_POLL_INTERVAL_MS
2624
+ exports.NOTICE_POLL_RETRY_MS = NOTICE_POLL_RETRY_MS
2625
+ exports.fetchNoticeSnapshot = fetchNoticeSnapshot
2626
+ exports.postNoticeOperation = postNoticeOperation
2627
+ exports.NoticeSwitchRow = NoticeSwitchRow
2628
+ exports.NoticeSettings = NoticeSettings
2629
+ exports.startNoticePendingPoll = startNoticePendingPoll
2630
+ exports.registerNotice = registerNotice
2631
+ exports.ensureNoticeStyles = ensureNoticeStyles
1826
2632
  exports.DEFAULT_WORKSPACE_SETTING = DEFAULT_WORKSPACE_SETTING
1827
2633
  exports.WORKSPACE_ENABLED_SETTING = WORKSPACE_ENABLED_SETTING
1828
2634
  exports.API_PATH = API_PATH
1829
2635
  exports.ROW_BUTTON_CLASS = ROW_BUTTON_CLASS
2636
+ exports.ROW_BUTTON_OFF_CLASS = ROW_BUTTON_OFF_CLASS
1830
2637
  exports.ROW_BUTTON_LABEL = ROW_BUTTON_LABEL
2638
+ exports.ROW_BUTTON_LABEL_OFF = ROW_BUTTON_LABEL_OFF
1831
2639
  exports.SIDEBAR_SLOT_SELECTOR = SIDEBAR_SLOT_SELECTOR
1832
2640
  exports.WORKSPACE_ROW_SELECTOR = WORKSPACE_ROW_SELECTOR
1833
2641
  exports.FALLBACK_SETTLE_MS = FALLBACK_SETTLE_MS
@@ -1855,6 +2663,7 @@ window.__ModuleLoader__.load({
1855
2663
  exports.pickErrorMessage = pickErrorMessage
1856
2664
  exports.installWorkspaceRowButtons = installWorkspaceRowButtons
1857
2665
  exports.seatRowButton = seatRowButton
2666
+ exports.markRowButton = markRowButton
1858
2667
  exports.WorkdirsPanelIcon = WorkdirsPanelIcon
1859
2668
  exports.WorkdirsEntryIcon = WorkdirsEntryIcon
1860
2669
  exports.WorkdirsDialog = WorkdirsDialog