dsh-better-notice 5.0.0-alpha.1 → 5.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +109 -47
  2. package/README.zh.md +107 -45
  3. package/client.js +946 -221
  4. package/package.json +19 -5
package/README.md CHANGED
@@ -13,7 +13,7 @@ dsh plugin --profile <profile> add dsh-better-notice
13
13
  ```
14
14
 
15
15
  - Package: `dsh-better-notice` (module id is the same string)
16
- - Version: `4.6.5` (see [CHANGELOG.md](CHANGELOG.md))
16
+ - Version: `5.1.7` (see [CHANGELOG.md](CHANGELOG.md))
17
17
  - License: MIT
18
18
  - Settings live in **account menu → Settings → “Better Notice”** (a `settings.section` entry, id `hotlist`, order `30`)
19
19
 
@@ -46,17 +46,19 @@ dsh plugin --profile <profile> add dsh-better-notice
46
46
 
47
47
  ## What it does
48
48
 
49
- Three surfaces, one shared decision:
49
+ Four surfaces, one shared decision:
50
50
 
51
51
  1. **Conversation tab row** — each hot session takes one tab seat; click it to switch. The current session does not take a seat (it *is* the “Chat” tab).
52
52
 
53
53
  ![Tab row seats](screenshots/tab-seats.png)
54
54
 
55
- 2. **Left sidebar** — one status glyph in front of every session title (emoji by default, or an uploaded square image), optionally animated.
55
+ 2. **Window top bar (Windows desktop, new in v5)** — a row of session pills in the 40 px title strip, next to the tab row. It **coexists** with the tab row instead of replacing it; the two split the six categories. See [The window top bar](#the-window-top-bar-windows-desktop).
56
+
57
+ 3. **Left sidebar** — one status glyph in front of every session title (emoji by default, or an uploaded square image), optionally animated.
56
58
 
57
59
  ![Sidebar status glyphs](screenshots/sidebar-glyphs.png)
58
60
 
59
- 3. **On state change** — one sound plus one popout image that slides in from a window edge.
61
+ 4. **On state change** — one sound plus one popout image that slides in from a window edge.
60
62
 
61
63
  The glyph can also be your own square picture instead of an emoji:
62
64
 
@@ -84,16 +86,18 @@ Only those three states ship raster art (4 tiers each: 64 / 320 / 780 / 1560 px,
84
86
 
85
87
  ## Categories at a glance
86
88
 
87
- | Category | When it appears | Tab seat | Sidebar glyph | Popout image | Sound |
88
- |---|---|---|---|---|---|
89
- | 📌 `pinned` | You pinned it in the **host's own** UI (hover/right-click a sidebar row → “Pin session”) | ✅ | ✅ | — | — |
90
- | ⛔ `error` | The agent reported an error (`api-session/error`), expires after 10 min | ✅ | ✅ | ✅ | 🔈 |
91
- | 🔐 `pending` | Waiting for your approval / answer / plan review (secondary glyph ❓) | ✅ | ✅ | ✅ | 🔈 |
92
- | ✅ `completed` | This plugin saw it finish, within the last 7 min | ✅ | ✅ | ✅ | 🔈 |
93
- | ⏳ `running` | Working (includes “a subagent is running, parent idle”) | ✅ | ✅ | — | — |
94
- | 🧊 `idle` | Idle, and last activity is inside the *recent window* (default 5 min) | ✅ | ✅ | — | — |
89
+ | Category | When it appears | Tab row | Window top bar | Sidebar glyph | Popout image | Sound |
90
+ |---|---|---|---|---|---|---|
91
+ | 📌 `pinned` | You pinned it in the **host's own** UI (hover/right-click a sidebar row → “Pin session”) | ✅ | — | ✅ | — | — |
92
+ | ⛔ `error` | The agent reported an error (`api-session/error`), expires after 10 min | — | ✅ | ✅ | ✅ | 🔈 |
93
+ | 🔐 `pending` | Waiting for your approval / answer / plan review (secondary glyph ❓) | — | ✅ | ✅ | ✅ | 🔈 |
94
+ | ✅ `completed` | This plugin saw it finish, within the last 7 min | — | ✅ | ✅ | ✅ | 🔈 |
95
+ | ⏳ `running` | Working (includes “a subagent is running, parent idle”) | — | — | ✅ | — | — |
96
+ | 🧊 `idle` | Idle, and last activity is inside the *recent window* (default 5 min) | ✅ | — | ✅ | — | — |
97
+
98
+ ✅ = on by default · — = off by default · 🔈 = a bundled sound exists but **stays silent until you enable that category** in settings.
95
99
 
96
- ✅ = on by default · — = off by default (popout and sound are opt-in per category) · 🔈 = a bundled sound exists but **stays silent until you enable that category** in settings.
100
+ The two seat surfaces **split the six categories** (see [The window top bar](#the-window-top-bar-windows-desktop)): `pinned` / `idle` take tab-row seats, `error` / `pending` / `completed` take top-bar pills, and `running` takes neither — a session is never drawn on both. All six still get a sidebar glyph.
97
101
 
98
102
  ### How a session is classified
99
103
 
@@ -105,7 +109,7 @@ error > pending > completed > running > pinned > idle
105
109
 
106
110
  Rationale: an error is a root cause and a pending approval is often its consequence, so the error wins; pinning is just “I want to look at this first”, not “something is happening”, so it ranks last of the *active* states. This order is **not configurable** — changing it changes semantics, not looks.
107
111
 
108
- **Display order** is a different thing and *is* configurable per category (`order`, default `error 10 / pending 10 / pinned 15 / completed 20 / running 30 / idle 50`). `error` and `pending` tie by default; ties fall back to the classification priority above, so what you actually see is *error → pending → pinned → completed → running → idle*.
112
+ **Display order** is a different thing and *is* configurable per category (`order`, default `error 10 / pending 10 / completed 20 / running 30 / idle 50 / pinned 60`). `error` and `pending` tie by default; ties fall back to the classification priority above, so the vertical order you actually see is *error → pending → completed → running → idle → pinned* — pinning moves a session to the top of its own surface via `pinned.maxSeats`, not by jumping the display order.
109
113
 
110
114
  One mechanism is independent of all surfaces: **“just finished” survives a page reload.** The host's own `completionUnread` only lives for 1–3 minutes and is cleared as soon as you open the session, so Better Notice records the `running true → not running` transition itself (batched writes, 500 ms), and after a reload replays it subject to `collect.completedTtlMs` and the current workspace. Refreshing the page does not lose your ✅ sessions.
111
115
 
@@ -121,7 +125,7 @@ dsh plugin --profile <profile> add dsh-better-notice
121
125
  dsh --profile <profile>
122
126
  ```
123
127
 
124
- - **Published on npm:** [dsh-better-notice](https://www.npmjs.com/package/dsh-better-notice) (current `4.6.5`). If npm is unreachable, the same tarball is attached to the [latest release](https://github.com/MeruSu/better-notice/releases) — `dsh plugin --profile <profile> add <absolute path>/merusu-dsh-better-notice-4.6.3.tgz` gives the same result and the same restart.
128
+ - **Published on npm:** [dsh-better-notice](https://www.npmjs.com/package/dsh-better-notice) (current `5.1.7`). If npm is unreachable, the same tarball is attached to the [latest release](https://github.com/MeruSu/better-notice/releases) — `dsh plugin --profile <profile> add <absolute path>/dsh-better-notice-5.1.7.tgz` gives the same result and the same restart.
125
129
  - **Use an absolute path, forward slashes** when you install from a local directory instead of the registry: `dsh plugin` forwards the argument to pnpm *inside the profile directory*, so a relative path points into the profile.
126
130
  - Installing from a directory uses `link:` — code edits need no reinstall. `client.js` changes apply on page refresh; `index.js` / `cordis.patch.yml` / `package.json` changes need a GUI restart.
127
131
  - Install from a git checkout when you also want the TTS helper (see [Sounds](#sounds-and-the-bundled-tts-tool)). The npm package's `files` field only ships `index.js`, `client.js`, `cordis.patch.yml` and `assets`, so `tools/` is **not** in it.
@@ -146,7 +150,7 @@ The host route is created during process start-up, so a page refresh is not enou
146
150
 
147
151
  In the GUI's browser console:
148
152
 
149
- 1. `window.__HOTLIST_VERSION__` → the version string, e.g. `'4.6.5'`.
153
+ 1. `window.__HOTLIST_VERSION__` → the version string, e.g. `'5.1.7'`.
150
154
  2. `Object.keys(window.__HOTLIST__)` → `['version','settings','assets']`.
151
155
  3. Settings → the left nav has **“Better Notice”**, and the page shows a global block plus **6** collapsible category cards.
152
156
  4. Session rows in the sidebar now start with a status glyph.
@@ -179,6 +183,47 @@ await fetch('/hotlist-sound/attention.wav').then(r => [r.status, r.headers.get('
179
183
 
180
184
  Keys are only handled when the page has focus region, no menu/dialog is open, it is not a long press, not an IME composition, and `Ctrl`/`Cmd` is not held. `Alt+1..9` direct jump is **not implemented** — bare Alt+digit is rejected by the host's `isWebBindingAllowed`, and the third-party `dsh-session-hotkeys` grabs it in the capture phase.
181
185
 
186
+ ## The window top bar (Windows desktop)
187
+
188
+ On Windows the GUI has a 40 px title strip above everything. The plugin can draw a row of session pills into it, next to the tab row — **not instead of it**.
189
+
190
+ | What you get | Field | Notes |
191
+ |---|---|---|
192
+ | Pills in the strip | `positions.topbar` | `true` by default on Windows; no effect in a browser |
193
+ | See it in a browser | `topbar.previewInWeb` | the only way to preview the band outside the desktop shell |
194
+ | Style | `topbar.mode` *(API only)* | `chips` (pills) / `count` (a single “N waiting” chip) / `off` |
195
+ | How many pills | `topbar.maxSeats` | `1`–`12`; the rest collapse into a `+N` chip that opens the hidden ones |
196
+ | Pill width | `topbar.chipWidthPct` | **percent**, `-30`…`+30`, `0` = untouched; a pill never goes below 72 px or above 140 px |
197
+ | Space for the window controls | `topbar.rightReservePx` *(API only)* | `0`–`400` px, for a custom title bar |
198
+
199
+ - The band is a transparent overlay with `pointer-events:none`; only the pills take clicks and they are marked `-webkit-app-region:no-drag`, so **the drag strip stays draggable**. The 40 px strip belongs to the window, not to us.
200
+ - The left edge is measured with a hit test (`elementsFromPoint`): only an element that hugs the left edge *and* is at most 480 px wide counts as occupying space. A full-width container would otherwise push the pills off-screen.
201
+ - The right side reserves a hard-coded `WINDOW_CONTROLS_GUARD = 138` px for the window buttons. It is **not a setting**.
202
+ - `running` never appears here, and the current session is skipped. A session shows on **one** surface only (`error`/`pending`/`completed` on the band, `pinned`/`idle` in the tab row).
203
+ - macOS: not supported, and it will not be. Nothing to attach to.
204
+
205
+ ### Label display modes (how a pill / seat draws its name)
206
+
207
+ `topbar.titleMode` decides what sits in front of the session name:
208
+
209
+ | Value | You see | Notes |
210
+ |---|---|---|
211
+ | `text` | the name only | the leading emoji is stripped |
212
+ | `icon` | the plugin's own category glyph | the leading emoji is stripped |
213
+ | `custom` | the emoji the session name starts with, when it has one | per-category opt-in below |
214
+
215
+ All three modes **strip the emoji already in the session title**, so you never get two emoji side by side. Old setting values (`unified` / `two` / `smart` / `auto`) are aliased onto the three current names, so an upgrade never breaks a stored choice.
216
+
217
+ ### Custom icon scope — which categories borrow the title emoji
218
+
219
+ `topbar.customScope.<category>` is six booleans, and it **only applies to the `custom` mode**. Turning a category off there means that category keeps drawing the plugin's own glyph even when the title starts with an emoji.
220
+
221
+ | Field | Values | Default |
222
+ |---|---|---|
223
+ | `topbar.customScope.{pinned,error,pending,completed,running,idle}` | `true` / `false` | all six `true` |
224
+
225
+ Settings shows this as six chips on the second row of the category card, and the whole row disappears while the mode is `text` or `icon`, because it would do nothing there.
226
+
182
227
  ## Customization index
183
228
 
184
229
  Field reference tables are below; this section is organized by intent. Everything here is editable in **Settings → “Better Notice”** and applies immediately, no reload. The few rows marked *API only* have no control yet — use `window.__HOTLIST_SETTINGS__.set(...)`.
@@ -187,15 +232,17 @@ Field reference tables are below; this section is organized by intent. Everythin
187
232
 
188
233
  ### Placement — where it shows up
189
234
 
190
- Three surfaces are independent; you can enable one, two, or none (none = the plugin is effectively paused without uninstalling).
235
+ Each surface has its own master switch (`positions.tab` / `positions.sidebar` / `positions.topbar`); you can enable one, two, or none (none = the plugin is effectively paused without uninstalling).
191
236
 
192
237
  | I want… | Field | Values | Default |
193
238
  |---|---|---|---|
194
239
  | Only in the conversation tab row | `positions.tab` | `true` / `false` | `true` |
195
240
  | Also show a status glyph in the sidebar | `positions.sidebar` *(API only)* | `true` / `false` | `true` |
196
- | Put it in the window top bar | `positions.topbar` | `false` (**not implemented**, greyed out in settings) | `false` |
241
+ | Put it in the window top bar (Windows desktop) | `positions.topbar` | `true` / `false` | `true` |
197
242
 
198
- > **Top bar: read this before you wait for it.** On **Windows** it is planned for the **next major version (v5)** — today the `positions.topbar` row exists only to keep the maths honest and is greyed out with “coming soon”. On **macOS it is not supported and will not be**: the host's top region behaves differently there, so there is nothing to attach to. Do not tell a Mac user to wait for it.
243
+ > **The top bar is Windows-only, and macOS will never get it.** On Windows desktop the plugin draws into the 40 px title strip; in a browser tab the band stays down unless you switch on `topbar.previewInWeb`. On **macOS it is not supported and will not be**: the host's top region behaves differently there, so there is nothing to attach to. Do not tell a Mac user to wait for it.
244
+ >
245
+ > The top bar and the tab row **coexist** — neither replaces the other. They split the six categories instead: `error` / `pending` / `completed` live in the band, `pinned` / `idle` take tab seats, and `running` deliberately appears on **neither** (it is not news). Both surfaces draw from the same classification, so a session never shows up as two different states.
199
246
 
200
247
  Per-category placement is a second layer: `categories.<id>.surfaces.{tab,sidebar,topbar}` decides whether *that* category may appear on *that* surface. Settings shows these as read-only chips; change them through the API.
201
248
 
@@ -222,9 +269,9 @@ Category ids: `pinned` · `error` · `pending` · `completed` · `running` · `i
222
269
  | I want… | Field | Values | Default |
223
270
  |---|---|---|---|
224
271
  | Turn a whole category off | `categories.<id>.enabled` | `true` / `false` | all six `true` |
225
- | Decide where that category sits | `categories.<id>.order` | `1`–`99` | `error 10 / pending 10 / pinned 15 / completed 20 / running 30 / idle 50` |
272
+ | Decide where that category sits | `categories.<id>.order` | `1`–`99` | `error 10 / pending 10 / pinned 60 / completed 20 / running 30 / idle 50` |
226
273
  | Sort within the category | `categories.<id>.sortBy` | `'time'` (recent activity first) / `'manual'` (host pin order) | `'time'` (`pinned` = `'manual'`) |
227
- | Restrict it to some surfaces | `categories.<id>.surfaces.{tab,sidebar,topbar}` | three booleans | first two `true`, `topbar` `false` |
274
+ | Restrict it to some surfaces | `categories.<id>.surfaces.{tab,sidebar,topbar}` | three booleans | `tab` for `pinned`/`idle`, `topbar` for `error`/`pending`/`completed`, `sidebar` for all; `running` has none of the first two |
228
275
  | Choose where the icon comes from | `categories.<id>.glyph.source` | `'emoji'` / `'upload'` / `'popout'` | `'emoji'` |
229
276
  | Change the glyph character | `categories.<id>.glyph.value` | emoji, at most 8 code points | per category, see the table above |
230
277
  | Use your own square image | `categories.<id>.glyph.uploads` | up to 6, ≤16 KB each, `png/jpeg/webp/gif` | `[]` |
@@ -266,7 +313,7 @@ Field-name-level documentation for people writing scripts.
266
313
 
267
314
  - **Collapsed card:** glyph preview, category name, `· order N`, five read-only chips (Tab / Bar / Top / Pop / Snd — highlighted = on), caret. Click the row to expand; **several cards may be open at once**. Open state lives in `sessionStorage` (`dsh.hotlist.open`), never in settings.
268
315
  - **Expanded area** (compact multi-column): ① glyph (source / upload / value / frequency slider); ② motion (`motion` + target `glyph`/`row`); ③ popout (enabled / image / entrance·ambient·exit / side·display time); ④ sound (enabled / source / preview); ⑤ order.
269
- - **Global block:** seat limit (with “auto”), **pinned seats**, completed TTL in minutes, global volume, **recent window** (minutes), `positions.tab`, `positions.topbar` (greyed “coming soon”), `takeOverStatusDot`.
316
+ - **Global block:** scope (workspace / all / smart), seat limit (with “auto”), **pinned seats**, completed TTL in minutes, global volume, **recent window** (minutes), `positions.tab`, `positions.topbar`, `positions.sidebar` (API only), `takeOverStatusDot`. The band block (style / label display / pill count / pill width / web preview) only exists while `positions.topbar` is on.
270
317
  - Out-of-range writes are clamped. A failed write (localStorage quota) is reported in the page footer and through `window.__HOTLIST_SETTINGS__.lastError()`.
271
318
 
272
319
  ### Global defaults
@@ -276,7 +323,16 @@ Field-name-level documentation for people writing scripts.
276
323
  | `maxSeats` | `6` | `1`–`12`, or `'auto'` (follows the real tab-row width) |
277
324
  | `positions.tab` | `true` | master switch for tab seats |
278
325
  | `positions.sidebar` | `true` | master switch for the sidebar (no settings row; API only) |
279
- | `positions.topbar` | `false` | window top bar, **not implemented**, greyed out |
326
+ | `positions.topbar` | `true` | Windows desktop title strip; no effect in a browser unless `topbar.previewInWeb` |
327
+ | `scope` | `'smart'` | `workspace` (current workspace only) / `all` (every workspace) / `smart` (every workspace, but a cross-workspace pin is demoted out of the pinned row) |
328
+ | `topbar.mode` | `'chips'` | `chips` / `count` / `off` (no settings row; API only) |
329
+ | `topbar.titleMode` | `'custom'` | `text` / `icon` / `custom`; old names `unified` / `two` / `smart` / `auto` are aliased |
330
+ | `topbar.maxSeats` | `6` | `1`–`12`, independent of `maxSeats` |
331
+ | `topbar.chipWidthPct` | `0` | **percent**, `-30`…`+30`; `0` reproduces the shipped widths exactly |
332
+ | `topbar.previewInWeb` | `false` | draw the band in a browser too |
333
+ | `topbar.rightReservePx` | `0` | `0`–`400` px (no settings row; API only) |
334
+ | `topbar.customScope` | all `true` | six booleans, only read in `custom` mode |
335
+ | `choices` | `{}` | keys the user actually picked; written by `writeSettings()`, never by hand |
280
336
  | `takeOverStatusDot` | `true` | take over the status dot drawn by other plugins (`dsh-sessions-manager`) |
281
337
  | `hideNativeDot` | `true` | hide the host's native status slot (no settings row since 4.4; field kept) |
282
338
  | `collect.completedTtlMs` | `420000` (7 min) | 1–1440 minutes |
@@ -295,7 +351,7 @@ Field-name-level documentation for people writing scripts.
295
351
  | `enabled` | all six `true` | off = no classification, no seat, no sidebar glyph |
296
352
  | `order` | `10`…`50` | 1–99, display order only; `error` and `pending` tie at `10`, resolved by classification priority |
297
353
  | `sortBy` | `'time'` (`pinned`: `'manual'`) | `time` = recent activity first; `manual` = host pin order |
298
- | `surfaces.tab` / `.sidebar` / `.topbar` | `true` / `true` / `false` | read-only chips in settings |
354
+ | `surfaces.tab` / `.sidebar` / `.topbar` | `pinned`+`idle`: `true`/`true`/`false` · `error`+`pending`+`completed`: `false`/`true`/`true` · `running`: `false`/`true`/`false` | read-only chips in settings |
299
355
  | `glyph.source` | `'emoji'` | `emoji` / `upload` / `popout` (reuse that category's popout art) |
300
356
  | `glyph.value` | per category | emoji, at most 8 code points |
301
357
  | `glyph.uploads` | `[]` | up to 6, ≤16 KB each, `png/jpeg/webp/gif` (**no SVG**) |
@@ -324,7 +380,7 @@ Read this part before touching anything. The user says one sentence; map it to a
324
380
  | “Make it more obvious” | Add popout/sound/motion | `popout.enabled`, `sound.enabled`, `frequency` up | `popout.displayMs: 12000`, `frequency: 1`, `overlay.sizeMin/sizeMax` up |
325
381
  | “I can't see category X” | Three switches in order | ① `categories.<id>.enabled` ② its `surfaces` ③ for `idle`, the recent window | ① must be `true` ② that surface's bit must be `true` ③ `recentWindowMinutes > 0` |
326
382
  | “My change did nothing” | Depends what changed | `client.js` → refresh page; `index.js` → **restart GUI**; a setting → check `lastError()`, it was probably clamped | — |
327
- | “When does the top bar work?” | Not implemented | `positions.topbar` / `surfaces.topbar` | always `false`; do not promise it |
383
+ | “When does the top bar work?” | Windows desktop only (macOS never) | `positions.topbar` + `topbar.previewInWeb` for the browser | `true` by default on Windows; in a browser only with the preview switch |
328
384
  | “How do I pin / unpin?” | Not ours | the host's own sidebar row menu | we only read it; `pinned.maxSeats` limits how many show |
329
385
 
330
386
  Two ways to write settings, same effect:
@@ -346,7 +402,7 @@ Rules for agents:
346
402
  2. **Never change `classify()` order to “fix” visibility.** Use `enabled`, `surfaces`, `order`. Priority is semantics.
347
403
  3. **Never reset user settings.** No `reset()`, no clearing the storage key, no “helpful” forced defaults in `sanitize*` — see [Modifying this plugin](#modifying-this-plugin).
348
404
  4. **Pinning is the host's job.** Do not invent a pin menu; read `pinnedSessionIds`.
349
- 5. **Top bar does not exist yet.** Say so instead of faking it.
405
+ 5. **The top bar is Windows-desktop only.** In a browser it needs `topbar.previewInWeb`; on macOS say plainly that it is not supported and never will be.
350
406
 
351
407
  ## Sounds and the bundled TTS tool
352
408
 
@@ -444,7 +500,7 @@ s.lastError() // last storage write error (string or null)
444
500
 
445
501
  // settings + the three asset pools
446
502
  const api = window.__HOTLIST__;
447
- api.version // '4.6.5'
503
+ api.version // '5.1.7'
448
504
  api.settings.get() / .set(patch) / .reset() / .subscribe(fn)
449
505
  api.settings.backups() // → [{ at, version }]
450
506
  api.settings.restore(index) // → { ok, at? }
@@ -452,11 +508,17 @@ api.assets.add(category, kind, { name, dataUrl }) // → { ok, reason?, bytes?
452
508
  api.assets.list(category, kind) // → [{ name, bytes }]
453
509
  api.assets.clear(category, kind) // → boolean
454
510
  api.assets.preview('pending') // play a cue now → boolean
511
+
512
+ // read-only probes, for scripts and acceptance tests
513
+ api.topbar() // → { mounted, mode, titleMode, left, leftSource, measured, reserve, currentId, capacity, chips }
514
+ api.export({ format: 'md' }) // session list as md | txt | json (a bare call works)
455
515
  ```
456
516
 
457
517
  - `kind`: `'glyph'` | `'popout'` | `'sound'`.
458
518
  - `category`: `'pinned' | 'error' | 'pending' | 'completed' | 'running' | 'idle'`.
459
519
  - `add`'s `reason` is one of `invalid` / `full` / `refused` / `budget`.
520
+ - `export()`: `format` is `md` | `txt` | `json` (**not** `markdown`); explicit `ids` win over `scope`; `md`/`txt` carry a BOM, `json` does not; the call is pure, so two exports of one snapshot are byte-identical.
521
+ - `topbar()` never writes. `leftSource` tells you whether the band's left edge came from `occupied` (a hit test), `menu` or `fallback` — that is what the acceptance script reads.
460
522
 
461
523
  **Two traps:**
462
524
 
@@ -473,7 +535,7 @@ More examples (bulk import, loading assets from URLs, the three preview paths) i
473
535
  4. **There is no separate “subagent” state.** The host exposes no such signal, so “a subagent is running” is folded into `running`.
474
536
  5. **`plan-review` and ❓ are not distinguished.** The host exposes a single `pendingInteraction.kind`.
475
537
  6. **Tab labels are strings only.** The host renders them from a string, so tabs only ever show an emoji, never an uploaded image.
476
- 7. **The window top bar is not implemented.** `positions.topbar` / `surfaces.topbar` only feed the visibility maths.
538
+ 7. **The window top bar needs the Windows desktop shell.** In a browser it only appears with `topbar.previewInWeb`; on macOS it is not supported at all. While it is mounted the tab row keeps working — they are independent surfaces.
477
539
  8. **Tab and sidebar do not share one classifier.** `classify()` (tab) and `sidebarStateOf()` (sidebar) read the same data; since 4.6.0 their priorities agree (`error > pending > completed > running > pinned > idle`) but they are still different functions: the sidebar also honours the host's `completionUnread`, the tab surface only trusts this plugin's own `completedAt`, and sidebar errors carry their own 30 min TTL (`SIDEBAR_ERROR_TTL_MS`) instead of the 10 min the tab surface uses (`ERROR_TTL_MS`). So a session that finishes and immediately restarts can briefly show “tab ✅, sidebar ⏳”. This is a deliberate trade-off between the two surfaces, not a bug to fix.
478
540
  9. **The tab row can run out of room.** The host folds extra tabs into an unreachable overflow menu, so this plugin computes its own limit from the row width.
479
541
  10. **Motion is sampled, not always-on.** Each category rolls its own frequency; on a hit, one seat of that category plays one animation. At most 4 animate at once (`MOTION_CONCURRENCY`).
@@ -507,24 +569,24 @@ ctx.get('uiWorkspace').workspaces.list ← workspace grouping + pinn
507
569
  ctx.remote.$on('api-session/error', …) ← agent-level errors
508
570
  │
509
571
  ▼
510
- classify() category + token client.js:1590
511
- sortSeats() ordering within a category client.js:1654
512
- buildSeatPlan() apply limits, assign order client.js:1677
572
+ classify() category + token client.js:1900
573
+ sortSeats() ordering within a category client.js:1977
574
+ buildSeatPlan() apply limits, assign order client.js:2000
513
575
  │
514
576
  ┌───────────┴────────────┬──────────────────┐
515
577
  ▼ ▼ ▼
516
578
  mountTabSeats() createSidebarAdapter() HotlistSettingsPage()
517
579
  tab seats (conversation.view) sidebar DOM settings page (settings.section)
518
- client.js:4291 client.js:1974 client.js:2668
580
+ client.js:5233 client.js:2633 client.js:3368
519
581
  ```
520
582
 
521
- The three pure functions (`classify` / `sortSeats` / `buildSeatPlan`) never touch the DOM and can be fed fake data — they are the safest entry point for behaviour changes. Their results are also the only debugging handle besides `window.__HOTLIST__`: the export table at the end of `client.js` (`client.js:4712` onwards) exposes `classify`, `sortSeats`, `buildSeatPlan`, `readSettings`, `defaultSettings`, `sanitizeSettings`, `sidebarStateOf`, `HotlistSettingsPage` and more, and the unit tests run the real source through it.
583
+ The three pure functions (`classify` / `sortSeats` / `buildSeatPlan`) never touch the DOM and can be fed fake data — they are the safest entry point for behaviour changes. Their results are also the only debugging handle besides `window.__HOTLIST__`: the export table at the end of `client.js` (`client.js:6036` onwards) exposes `classify`, `sortSeats`, `buildSeatPlan`, `readSettings`, `defaultSettings`, `sanitizeSettings`, `sidebarStateOf`, `HotlistSettingsPage` and more, and the unit tests run the real source through it.
522
584
 
523
585
  ### Touch points: change one thing, change these
524
586
 
525
587
  **1) Add a category**
526
588
 
527
- `CATEGORY_IDS` (`client.js:122`) → `CATEGORY_ORDER` (`:123`) → `CATEGORY_GLYPHS` (`:141`) → `CATEGORY_MOTION` (`:142`) → `CATEGORY_SURFACES` (`:144`, value is `{tab, sidebar, topbar}`) → defaults in `defaultCategory()` (`:246`) → accept the key in `sanitizeSettings()` (`:764`) → one rule in `classify()` (`:1477`) → one entry each in the dictionaries `ZH` (`:307`) / `EN` (`:431`) → one new group in `test/classify.test.mjs`. Missing `CATEGORY_GLYPHS` usually just means a blank icon; missing `CATEGORY_SURFACES` throws inside `defaultCategory()`.
589
+ `CATEGORY_IDS` (`client.js:180`) → `CATEGORY_ORDER` (`client.js:181`) → `CATEGORY_GLYPHS` (`client.js:200`) → `CATEGORY_MOTION` (`client.js:201`) → `CATEGORY_SURFACES` (`client.js:238`, value is `{tab, sidebar, topbar}`) → defaults in `defaultCategory()` (`:246`) → accept the key in `sanitizeSettings()` (`:764`) → one rule in `classify()` (`:1477`) → one entry each in the dictionaries `ZH` (`:307`) / `EN` (`:431`) → one new group in `test/classify.test.mjs`. Missing `CATEGORY_GLYPHS` usually just means a blank icon; missing `CATEGORY_SURFACES` throws inside `defaultCategory()`.
528
590
 
529
591
  **2) Add a setting**
530
592
 
@@ -532,7 +594,7 @@ Five places, or you get “writes but does not read” / “Chinese leaking into
532
594
 
533
595
  **3) Add a motion**
534
596
 
535
- Add the name to `MOTION_NAMES` (`:153`) → a duration to `MOTION_MS` (`:1736`) → a `@keyframes hl-<name>` to `SIDEBAR_STYLE` (`:1758`) (animate `transform` / `opacity` only, never layout-triggering properties) → for the title variant, one entry each in `TEXT_MOTION` (`:1745`) and `TEXT_BURST` (`:1752`) plus `@keyframes hl-text-<name>`. Concurrency is capped by `MOTION_CONCURRENCY` (`:192`).
597
+ Add the name to `MOTION_NAMES` (`client.js:247`) → a duration to `MOTION_MS` (`client.js:2375`) → a `@keyframes hl-<name>` to `SIDEBAR_STYLE` (`client.js:2397`) (animate `transform` / `opacity` only, never layout-triggering properties) → for the title variant, one entry each in `TEXT_MOTION` (`client.js:2384`) and `TEXT_BURST` (`client.js:2391`) plus `@keyframes hl-text-<name>`. Concurrency is capped by `MOTION_CONCURRENCY` (`client.js:286`).
536
598
 
537
599
  **4) Add a popout-art slot**
538
600
 
@@ -540,11 +602,11 @@ Art is `assets/<category>-<tier>.webp` (tiers 64 / 320 / 780 / 1560) for the thr
540
602
 
541
603
  **5) Replace a bundled sound**
542
604
 
543
- `BUILTIN_SOUND` (`:212`) is the only table mapping category → filename (`SOUND_CATEGORIES` (`:214`) is derived from it). Put the new wav in `assets/sounds/`, edit the table, keep `assets/sounds/manifest.json` in sync. The URL is built from `SOUND_URL` (`:211`) as `/hotlist-sound/<file>`; the actual serving is `serveSound()` in `index.js` — a **GUI restart is mandatory** (host routes register at start-up).
605
+ `BUILTIN_SOUND` (`client.js:306`) is the only table mapping category → filename (`SOUND_CATEGORIES` (`client.js:308`) is derived from it). Put the new wav in `assets/sounds/`, edit the table, keep `assets/sounds/manifest.json` in sync. The URL is built from `SOUND_URL` (`client.js:305`) as `/hotlist-sound/<file>`; the actual serving is `serveSound()` in `index.js` — a **GUI restart is mandatory** (host routes register at start-up).
544
606
 
545
607
  **6) Change ordering or priority**
546
608
 
547
- - **Display order:** `CATEGORY_ORDER` (`:123`) plus each category's `order`, consumed by `sortSeats()` (`:1541`). Placement only, never classification.
609
+ - **Display order:** `CATEGORY_ORDER` (`client.js:181`) plus each category's `order`, consumed by `sortSeats()` (`:1541`). Placement only, never classification.
548
610
  - **Classification priority:** the order of the `if`s inside `classify()` (`:1477`). A session belongs to one category; **first match wins**.
549
611
  - They are separate on purpose: users may change `order` but cannot decide who wins a session. Editing `classify` changes semantics — always re-run the `category order` and `sidebar priority` test groups.
550
612
 
@@ -554,10 +616,10 @@ Sidebar titles can carry a CSS animation; **tab seats cannot** — the tab label
554
616
 
555
617
  - Field: `settings.categories.<id>.glyph.motionTarget`
556
618
  - Values: `'glyph'` = icon only; `'row'` = icon + title, started and stopped together.
557
- - **Since 4.6.0 the default is `'row'`** (`defaultGlyph()`, `client.js:258`).
619
+ - **Since 4.6.0 the default is `'row'`** (`defaultGlyph()`, `client.js:327`).
558
620
 
559
621
  ```js
560
- // Option A: change the default for every category (source edit, defaultGlyph() at client.js:258)
622
+ // Option A: change the default for every category (source edit, defaultGlyph() at client.js:327)
561
623
  function defaultGlyph(category) {
562
624
  return {
563
625
  kind: 'emoji',
@@ -586,16 +648,16 @@ window.__HOTLIST_SETTINGS__.set({
586
648
 
587
649
  **Option C has a trap you must know:** `set` merges only two levels, and `glyph` / `popout` / `sound` are replaced as whole blocks — the line above resets `pending.glyph`'s `value`, `motion` and `frequency`. Do `get()`, edit the whole object, `set()`.
588
650
 
589
- Adding a new title motion: one entry each in `TEXT_MOTION` (`client.js:1858`, looping) and `TEXT_BURST` (`:1752`, one-shot), a duration in `MOTION_MS` (`:1736`), and `@keyframes hl-text-<name>` in `SIDEBAR_STYLE` (`:1758`). Title keyframes **only animate `opacity` and 1–2 px of translation, never `scale`** (text goes blurry); concurrency is still capped by `MOTION_CONCURRENCY` (`:192`) and the rate is the per-category `glyph.frequency` (0–1, `0` = never).
651
+ Adding a new title motion: one entry each in `TEXT_MOTION` (`client.js:2384`, looping) and `TEXT_BURST` (`client.js:2391`, one-shot), a duration in `MOTION_MS` (`client.js:2375`), and `@keyframes hl-text-<name>` in `SIDEBAR_STYLE` (`client.js:2397`). Title keyframes **only animate `opacity` and 1–2 px of translation, never `scale`** (text goes blurry); concurrency is still capped by `MOTION_CONCURRENCY` (`client.js:286`) and the rate is the per-category `glyph.frequency` (0–1, `0` = never).
590
652
 
591
653
  ### Upgrades never overwrite user settings
592
654
 
593
- **Promise: upgrading the plugin never changes a setting the user already chose.** A value that still equals one of our own earlier factory defaults is not a choice — it follows the current default (the `LEGACY_VALUES` bullet below). Everything else on disk wins. A field the user *did* pick is his even when the value coincides with a preset: `choices` (`client.js:719`) records those picks and the preset rule reads it before rewriting anything.
655
+ **Promise: upgrading the plugin never changes a setting the user already chose.** A value that still equals one of our own earlier factory defaults is not a choice — it follows the current default (the `LEGACY_VALUES` bullet below). Everything else on disk wins. A field the user *did* pick is his even when the value coincides with a preset: `choices` (`client.js:1805`) records those picks and the preset rule reads it before rewriting anything.
594
656
 
595
657
  How that is guaranteed:
596
658
 
597
659
  - **Defaults only fill gaps**; anything on disk wins. `sanitizeSettings()` starts from the defaults and then, field by field, keeps any valid stored value — so changing `CATEGORY_ORDER` or `defaultGlyph()` is invisible to existing users.
598
- - **Factory presets are ours, not the user's.** A stored value that only ever equalled a default an earlier release shipped — `💤` for the idle glyph, `blink` / `none` motion, `motionTarget: 'glyph'`, frequency `1`, sound off — follows the current default, so an existing install does eventually see the ice cube and the 4.6.3 motion table without the user re-doing anything. The table is `LEGACY_VALUES` (`client.js:152`); the same boundary already used for a category `order` that equals a historical default (`LEGACY_DEFAULTS`). As soon as the user edits that field the value is theirs and we never touch it again, and a value nobody ever shipped as a default is kept byte for byte. "Edited" is recorded explicitly: `choices` `{ "<path>": true }` (`client.js:702-737`) is written by `writeSettings()` from the incoming patch only, so a value the preset rule rewrote can never pass for a choice; `resetSettings()` clears the whole map because a reset is a fresh acceptance of the presets.
660
+ - **Factory presets are ours, not the user's.** A stored value that only ever equalled a default an earlier release shipped — `💤` for the idle glyph, `blink` / `none` motion, `motionTarget: 'glyph'`, frequency `1`, sound off — follows the current default, so an existing install does eventually see the ice cube and the 4.6.3 motion table without the user re-doing anything. The table is `LEGACY_VALUES` (`client.js:210`); the same boundary already used for a category `order` that equals a historical default (`LEGACY_DEFAULTS`). As soon as the user edits that field the value is theirs and we never touch it again, and a value nobody ever shipped as a default is kept byte for byte. "Edited" is recorded explicitly: `choices` `{ "<path>": true }` (`client.js:702-737`) is written by `writeSettings()` from the incoming patch only, so a value the preset rule rewrote can never pass for a choice; `resetSettings()` clears the whole map because a reset is a fresh acceptance of the presets.
599
661
  - **Adding a field:** a fallback in `sanitize*` is enough, **do not bump `SETTINGS_VERSION`**. A version bump claims “a migration is required”, and a wrong migration silently wipes user data — no structural change, no version change.
600
662
  - **Real structural changes** (dropping a field, changing its meaning) must keep the chain “read old → convert → drop the old field” (today that lives in `sanitizeSettings()`; the v3.1 → v4 migration went through it) and bump `SETTINGS_VERSION` by the smallest step. The `migrate v3 to v4` test group is the acceptance test for that chain.
601
663
 
@@ -622,7 +684,7 @@ The same ground as the pitfall list at the bottom of this file, in checklist for
622
684
  ```powershell
623
685
  node --check client.js # syntax. client.js lives inside a module loader, so a syntax error only explodes when the real page boots
624
686
  node --check index.js
625
- node test/classify.test.mjs # expect a final line: PASS — 65 groups
687
+ node test/classify.test.mjs # expect a final line: PASS — 93 groups
626
688
  ```
627
689
 
628
690
  Plus a **static gate before saving**, available on the maintainer's machine only (not shipped with the repo):
@@ -638,7 +700,7 @@ cd <the directory you cloned into>
638
700
 
639
701
  node --check client.js # client.js lives inside a module loader, so a syntax error only explodes when the real page boots
640
702
  node --check index.js
641
- node test/classify.test.mjs # expect a final line: PASS — 65 groups
703
+ node test/classify.test.mjs # expect a final line: PASS — 93 groups
642
704
  ```
643
705
 
644
706
  The unit tests read the real `client.js` source, wrap it in a stub `window.__ModuleLoader__` and run it: the fake browser is hand-written (`window` / `document`, no jsdom, `React` is a stub too), the code under test is the real source. They also include a few static scans: undeclared constants, CSS selector legality, and “shortcut labels must be thunks”.
@@ -653,7 +715,7 @@ index.js host side: registers the /hotlist-sound/ audio route
653
715
  cordis.patch.yml bundle insert line
654
716
  assets/ 3 categories with 4 raster tiers of popout art + svg for all six
655
717
  assets/sounds/ bundled cues (attention.wav / complete.wav)
656
- test/classify.test.mjs unit tests (65 groups)
718
+ test/classify.test.mjs unit tests (93 groups)
657
719
  docs/ architecture, testing, extending
658
720
  tools/meru-tts/ TTS helper (generates cues; not part of the running plugin)
659
721
  ```
@@ -674,7 +736,7 @@ Eleven things that have already cost real debugging time — including a broken
674
736
  4. **The static routes this plugin registers are unauthenticated.** `/hotlist-sound/*` and `/plugins/**/assets/*` do not check a token — anyone who can reach the port can fetch them.
675
737
  *If you ignore it:* you publish a path that leaks whatever you put behind it. Public assets only, never user data.
676
738
 
677
- 5. **User settings may never be overwritten.** `sanitizeSettings()` (`client.js:867`) is pure and falls back **per field, never wholesale**; unknown keys are parked in `PRESERVED_KEY` (`client.js:107`) and come back on the next read; out-of-range values are recorded (`outOfRangeOf`, `client.js:1239`) and restored; the last five writes live in a rolling backup ring (`BACKUP_KEY`, `client.js:109`, `BACKUP_LIMIT = 5`). Never rebuild the settings object from `defaultSettings()` inside a migration — that silently wipes every custom value the user has.
739
+ 5. **User settings may never be overwritten.** `sanitizeSettings()` (`client.js:1085`) is pure and falls back **per field, never wholesale**; unknown keys are parked in `PRESERVED_KEY` (`client.js:165`) and come back on the next read; out-of-range values are recorded (`outOfRangeOf`, `client.js:1530`) and restored; the last five writes live in a rolling backup ring (`BACKUP_KEY`, `client.js:167`, `BACKUP_LIMIT = 5`). Never rebuild the settings object from `defaultSettings()` inside a migration — that silently wipes every custom value the user has.
678
740
  *If you ignore it:* a user loses their whole configuration and cannot tell when it happened. Recovery is only `window.__HOTLIST__.settings.backups()` / `.restore(i)`.
679
741
 
680
742
  6. **Only bump `SETTINGS_VERSION` when the data structure really changed — and then write the migration in the same commit.** Cosmetic defaults and new optional fields need a fallback in `sanitize*`, not a version bump.
@@ -686,7 +748,7 @@ Eleven things that have already cost real debugging time — including a broken
686
748
  8. **Three hard caps are hard-coded, not settings.** `PLAN_MAX_ROWS = 37` (`client.js:203`) trims from the tail so the priority head always survives; `MOTION_CONCURRENCY = 4` (`client.js:217`) limits simultaneous animation; `PINNED_SEATS = {min: 0, max: 12}` (`client.js:200`).
687
749
  *If you ignore it:* you "fix" a truncated plan by raising `maxSeats`, which cannot exceed the row cap.
688
750
 
689
- 9. **There is exactly one source of truth for priority, and it is two functions.** `classify()` (`client.js:1590-1645`) decides tab seats and popouts; `sidebarStateOf()` (`client.js:1951-1972`) decides the sidebar glyph. They share the order `error > pending > completed > running > pinned > idle` but apply different extra conditions.
751
+ 9. **There is exactly one source of truth for priority, and it is two functions.** `classify()` (`client.js:1900-1969`) decides tab seats and popouts; `sidebarStateOf()` (`client.js:2605-2631`) decides the sidebar glyph. They share the order `error > pending > completed > running > pinned > idle` but apply different extra conditions.
690
752
  *If you ignore it:* you edit one and the same session shows two different states on two surfaces — that was a real bug before 4.6.0.
691
753
 
692
754
  10. **`dsh plugin add` on an already-installed plugin does not rewrite the bundle entry, and a running GUI holds the files open.** Remove first, then add; and close that profile's GUI before installing over it, otherwise the copy fails with `EPERM`.