@falling-ts/dsh-web-ding 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.cn.md CHANGED
@@ -13,7 +13,9 @@ Node/后端从不发声,也不走 Windows/系统通知。
13
13
  | 层 | 行为 |
14
14
  |----|------|
15
15
  | Host(`index.js` + `src/`) | 监听 `agent/status` 的 **idle 转变**(全部轮次与子代理结束、下一次人为对话之前;新建会话从未运行过的首次 idle 与重复 idle tick 都保持静默)。向 `falling-ts-web-ding` 设置命名空间写入 `{ phase:'done', at, sessionId }`。绝不发声、绝不调用系统。 |
16
- | 浏览器(`web/client.js`) | 经 `settingsScope` 实时镜像命名空间;收到 `at` 严格更新的 `done` 信号后用 Web Audio API 合成一声短"叮"(三个正弦振荡器 + 指数衰减包络),经标签页播放。 |
16
+ | 浏览器(`web/client.js`) | 经 `settingsScope` 实时镜像命名空间;收到 `at` 严格更新的 `done` 信号后用 Web Audio API 合成一声短"叮",经标签页播放——同一瞬间在右下角弹出 Win11 风格 toast。点击 toast 展开右侧消息列表,展示全部回合结束消息(存在浏览器 localStorage,上限 100、按 signal `at` 去重),支持单条删除与全部删除。全程纯前端:后端不发声、不弹系统通知。 |
17
+
18
+ 除了提示音,每次回合结束还会在右下角弹出一条 Win11 风格 toast(6 秒自动消失)。点击它即可打开右侧消息列表:最近 100 条回合结束消息保存在浏览器 localStorage(键 falling-ts-web-ding.notify.v1),每条可单独删除,顶部"全部删除"按钮一键清空。消息记录纯前端——Host 不读也不写。
17
19
 
18
20
  ## 安装
19
21
 
@@ -56,6 +58,15 @@ dsh plugin --profile web add /path/to/dsh-web-ding
56
58
  插件自身规则见 `AGENTS.md`(纯 Host 监听器、后端不发声、不弹系统通知、只用
57
59
  官方 settings 镜像通道通向浏览器)。
58
60
 
61
+ ## 效果截图
62
+
63
+ ![设置页——「回合结束提示音」分区,各旋钮免重启实时可调](assets/setting-ding.png)
64
+
65
+ *设置页——「回合结束提示音」分区:总开关、音量、频率、衰减时长全部免重启
66
+ 实时可调,并带"试听"按钮预热浏览器 AudioContext。*
67
+
68
+ ---
69
+
59
70
  ## License
60
71
 
61
72
  MIT
package/README.md CHANGED
@@ -15,7 +15,7 @@ a pure-listener Host half + a browser client half connected by the official
15
15
  | Layer | What happens |
16
16
  |-------|--------------|
17
17
  | Host (`index.js` + `src/`) | Listens for the `agent/status` **idle transition** (all turns done, including sub-agents, before the next human turn; a fresh idle session that never ran, and repeated idle ticks, stay silent). Publishes `{ phase:'done', at, sessionId }` into the `falling-ts-web-ding` settings namespace. Never emits audio, never calls the OS. |
18
- | Browser (`web/client.js`) | Mirrors the namespace live via `settingsScope`. On a strictly-newer `done` signal it synthesizes a short `ding` (three sine oscillators + exponential decay envelopes) with the Web Audio API and plays it through the tab. |
18
+ | Browser (`web/client.js`) | Mirrors the namespace live via `settingsScope`. On a strictly-newer `done` signal it synthesizes a short `ding` (three sine oscillators + exponential decay envelopes) with the Web Audio API and plays it through the tab — and, at the same moment, pops a Win11-style toast in the bottom-right corner. Clicking the toast slides in a notification drawer listing every turn-end message (kept in browser `localStorage`, capped at 100, deduped by signal `at`), with per-message delete and a clear-all button. Everything stays front-end: no backend audio, no OS notification. |
19
19
 
20
20
  ## Install
21
21
 
@@ -37,6 +37,13 @@ Requires the web app to ship the client bundle (the `dsh.client` declaration in
37
37
 
38
38
  Every session's agent turn end (transition to idle) rings; repeated idle ticks of an already-idle session and brand-new sessions that never ran stay silent.
39
39
 
40
+ Besides the ring, each turn end drops a bottom-right toast (Win11-style,
41
+ auto-dismisses after 6 s). Click it to open the right-side notification
42
+ drawer: the last 100 turn-end messages are kept in the browser's
43
+ `localStorage` (key `falling-ts-web-ding.notify.v1`), each row can be
44
+ deleted individually, and a "全部删除" button clears them all. The message
45
+ log is front-end only — the Host never reads or writes it.
46
+
40
47
  You can also adjust these from the **设置 → 回合结束提示音** panel, which
41
48
  includes a "试听" (preview) button.
42
49
 
@@ -61,6 +68,16 @@ See `AGENTS.md` for the plugin's own rules (pure Host listener, no backend
61
68
  audio, no OS notification, only sanctioned settings-mirror channel to the
62
69
  browser).
63
70
 
71
+ ## Screenshots
72
+
73
+ ![Settings page: the **ding** (回合结束提示音) section, live-editable controls](assets/setting-ding.png)
74
+
75
+ *Settings page: the **回合结束提示音** section. Master switch, volume,
76
+ frequency and decay length are all live-editable without a restart, with a
77
+ **试听** (preview) button to warm the browser AudioContext.*
78
+
79
+ ---
80
+
64
81
  ## License
65
82
 
66
83
  MIT
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falling-ts/dsh-web-ding",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DSH Cordis plugin: announces the moment an agent finishes (turns idle) by pushing a 'done' signal from the Host half into the falling-ts-web-ding settings namespace, which the browser client half mirrors live and answers with a synthesized 'ding' tone via the Web Audio API. The tone is played ENTIRELY by front-end JS in the browser — the Host/node backend never emits audio and no Windows/system notification is used.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -13,6 +13,7 @@
13
13
  "index.js",
14
14
  "src/",
15
15
  "web/",
16
+ "assets/",
16
17
  "cordis.patch.yml"
17
18
  ],
18
19
  "license": "MIT",
package/web/client.js CHANGED
@@ -102,7 +102,357 @@ window.__ModuleLoader__.load({
102
102
  return true;
103
103
  }
104
104
 
105
- // ── 命名空间快照 信号检测 → 播放 ------------------------------------------
105
+ // ── 回合结束消息缓存(浏览器侧,localStorage 持久化) ------------------------
106
+ // 每次回合结束写入一条消息:{ at, timeText, sessionId }。缓存按 at 去重、上限
107
+ // NOTIFY_CAP 条,存 localStorage;右下角弹窗与右侧消息列表通过 subscribeNotify
108
+ // 订阅变更。这是纯前端数据(零后端、零系统通知),与 Web Audio 播放同源。
109
+ const NOTIFY_KEY = "falling-ts-web-ding.notify.v1";
110
+ const NOTIFY_CAP = 100;
111
+ let notifyCache = null; // null = 尚未加载(惰性)
112
+ const notifyListeners = new Set();
113
+
114
+ function loadNotifyCache() {
115
+ if (notifyCache !== null) return notifyCache;
116
+ try {
117
+ const raw = window.localStorage.getItem(NOTIFY_KEY);
118
+ const arr = raw ? JSON.parse(raw) : [];
119
+ notifyCache = Array.isArray(arr) ? arr.filter((m) => m && typeof m.at === "number") : [];
120
+ } catch {
121
+ notifyCache = [];
122
+ }
123
+ return notifyCache;
124
+ }
125
+ function saveNotifyCache() {
126
+ try {
127
+ window.localStorage.setItem(NOTIFY_KEY, JSON.stringify(notifyCache.slice(0, NOTIFY_CAP)));
128
+ } catch { /* 无存储可用(private 模式等)时仅保留内存副本 */ }
129
+ }
130
+ function emitNotifyChange() {
131
+ notifyCache = loadNotifyCache();
132
+ const list = notifyCache;
133
+ notifyListeners.forEach((fn) => { try { fn(list); } catch {} });
134
+ }
135
+ function subscribeNotify(fn) {
136
+ notifyListeners.add(fn);
137
+ return () => notifyListeners.delete(fn);
138
+ }
139
+ /** 记录一条"回合结束"消息(同一 at 只记一次)。title 为会话真实标题(查不到时省略)。 */
140
+ function recordTurnEnd(at, sessionId, title) {
141
+ const list = loadNotifyCache();
142
+ if (list.some((m) => m.at === at)) return;
143
+ const d = new Date(at);
144
+ const pad = (n) => String(n).padStart(2, "0");
145
+ const timeText = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
146
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
147
+ list.unshift({
148
+ at,
149
+ timeText,
150
+ sessionId: typeof sessionId === "string" ? sessionId : undefined,
151
+ title: typeof title === "string" && title.trim() ? title : undefined,
152
+ });
153
+ if (list.length > NOTIFY_CAP) list.length = NOTIFY_CAP;
154
+ saveNotifyCache();
155
+ emitNotifyChange();
156
+ }
157
+ /**
158
+ * 通过 session.list 查询会话的真实标题(projections.values.title)。
159
+ * 信号里只有 sessionId,标题不在事件负载中;失败或未找到时返回
160
+ * undefined,调用方回退到无标题展示。
161
+ * @param {string|undefined} sessionId
162
+ * @returns {Promise<string|undefined>}
163
+ */
164
+ async function fetchSessionTitle(sessionId) {
165
+ if (!sessionId) return undefined;
166
+ try {
167
+ const res = await fetch(location.origin + "/api/session.list", {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({
171
+ type: "client-request",
172
+ rpcId: (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : String(Date.now())),
173
+ method: "session.list",
174
+ payload: {},
175
+ }),
176
+ });
177
+ const json = await res.json();
178
+ const result = json && json.result;
179
+ const items = result && result.ok && Array.isArray(result.value && result.value.items) ? result.value.items : [];
180
+ const row = items.find((it) => it && it.sessionId === sessionId);
181
+ const title = row && row.projections && row.projections.values && row.projections.values.title;
182
+ return typeof title === "string" && title.trim() ? title : undefined;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ /** 删除单条"回合结束"消息(按 at 匹配)。 */
188
+ function removeRecord(at) {
189
+ const list = loadNotifyCache();
190
+ const next = list.filter((m) => m.at !== at);
191
+ if (next.length === list.length) return;
192
+ notifyCache = next;
193
+ saveNotifyCache();
194
+ emitNotifyChange();
195
+ }
196
+ /** 清空全部"回合结束"消息。 */
197
+ function clearAllRecords() {
198
+ if (!loadNotifyCache().length) return;
199
+ notifyCache = [];
200
+ saveNotifyCache();
201
+ emitNotifyChange();
202
+ }
203
+
204
+ // ── 右下角通知弹窗(Win11 风格,纯内联样式/零资产) --------------------------
205
+ // 回合结束 ding 的同时弹出;6 秒自动消失,可手动关闭。点击主体在 C3 起
206
+ // 展开右侧消息列表。所有样式内联,不引入任何图片/CSS 资产。
207
+ const TOAST_TINT = "linear-gradient(135deg, rgba(0,120,212,0.16), rgba(0,120,212,0.05))";
208
+ let toastLayer = null;
209
+ function ensureToastLayer() {
210
+ if (toastLayer && document.body.contains(toastLayer)) return toastLayer;
211
+ toastLayer = document.createElement("div");
212
+ Object.assign(toastLayer.style, {
213
+ position: "fixed", right: "20px", bottom: "20px", zIndex: 2147483000,
214
+ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "12px",
215
+ pointerEvents: "none",
216
+ });
217
+ document.body.appendChild(toastLayer);
218
+ return toastLayer;
219
+ }
220
+ function dismissToast(el) {
221
+ el.style.transition = "opacity 0.22s ease, transform 0.28s cubic-bezier(0.16, 1, 0.3, 1)";
222
+ el.style.opacity = "0";
223
+ el.style.transform = "translateX(28px) scale(0.97)";
224
+ window.setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
225
+ }
226
+ function showTurnEndToast(msg) {
227
+ const layer = ensureToastLayer();
228
+ const d = new Date(msg.at);
229
+ const pad = (n) => String(n).padStart(2, "0");
230
+ const timeText = msg.timeText ||
231
+ (d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
232
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()));
233
+ const el = document.createElement("div");
234
+ Object.assign(el.style, {
235
+ pointerEvents: "auto", position: "relative", width: 360, minHeight: 88,
236
+ background: "rgba(255,255,255,0.88)", backdropFilter: "blur(20px) saturate(1.5)",
237
+ WebkitBackdropFilter: "blur(20px) saturate(1.5)",
238
+ border: "1px solid rgba(0,0,0,0.05)", borderRadius: 16,
239
+ boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 6px 16px rgba(0,0,0,0.08), 0 20px 48px rgba(0,0,0,0.14)",
240
+ overflow: "hidden",
241
+ display: "flex", alignItems: "stretch", cursor: "pointer",
242
+ opacity: "0", transform: "translateX(28px) scale(0.97)",
243
+ transition: "opacity 0.22s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)",
244
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif",
245
+ color: "#1f1f1f", fontSize: 13,
246
+ });
247
+ const accent = document.createElement("div");
248
+ Object.assign(accent.style, {
249
+ width: 4, flexShrink: 0, background: "#0078d4",
250
+ });
251
+ const body = document.createElement("div");
252
+ Object.assign(body.style, { padding: "17px 18px 16px", minWidth: 0 });
253
+ const head = document.createElement("div");
254
+ Object.assign(head.style, {
255
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10,
256
+ });
257
+ const title = document.createElement("span");
258
+ title.textContent = "回合结束";
259
+ Object.assign(title.style, { fontSize: 14, fontWeight: 600, letterSpacing: 0.2 });
260
+ const closeBtn = document.createElement("button");
261
+ closeBtn.type = "button";
262
+ closeBtn.textContent = "×";
263
+ Object.assign(closeBtn.style, {
264
+ border: "none", background: "transparent", cursor: "pointer",
265
+ fontSize: 17, lineHeight: 1, padding: "4px 8px", borderRadius: 8,
266
+ color: "rgba(0,0,0,0.55)", transition: "background 0.15s ease",
267
+ });
268
+ closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(0,0,0,0.06)"; });
269
+ closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; });
270
+ closeBtn.addEventListener("click", (ev) => {
271
+ ev.stopPropagation();
272
+ window.clearTimeout(el._timer);
273
+ dismissToast(el);
274
+ });
275
+ head.appendChild(title);
276
+ head.appendChild(closeBtn);
277
+ const text = document.createElement("div");
278
+ text.textContent = msg.title ? msg.title + " 已完成" : "agent 已完成回合——浏览器播放一声“叮”。";
279
+ Object.assign(text.style, { marginTop: 10, lineHeight: 1.65, color: "rgba(0,0,0,0.66)" });
280
+ const foot = document.createElement("div");
281
+ foot.textContent = timeText;
282
+ Object.assign(foot.style, { marginTop: 12, fontSize: 12, color: "rgba(0,0,0,0.48)" });
283
+ body.appendChild(head);
284
+ body.appendChild(text);
285
+ body.appendChild(foot);
286
+ el.appendChild(accent);
287
+ el.appendChild(body);
288
+ layer.appendChild(el);
289
+ void el.offsetHeight; // force reflow → 触发进入动画
290
+ el.style.opacity = "1";
291
+ el.style.transform = "translateX(0) scale(1)";
292
+ el._timer = window.setTimeout(() => dismissToast(el), 6000);
293
+ el.addEventListener("click", () => {
294
+ window.clearTimeout(el._timer);
295
+ openNotifyDrawer();
296
+ });
297
+ return el;
298
+ }
299
+
300
+ // ── 右侧消息列表面板(点击 toast 展开;纯前端 DOM,零资产) ------------------
301
+ // 覆盖层 + 右侧滑入面板:列出缓存里的回合结束消息(时间、会话摘要),空态
302
+ // 提示;通过 subscribeNotify 订阅缓存变更实时重绘。点击遮罩或 × 关闭。
303
+ let drawerHost = null;
304
+ let drawerUnsub = null;
305
+ function closeNotifyDrawer() {
306
+ if (drawerUnsub) { drawerUnsub(); drawerUnsub = null; }
307
+ if (!drawerHost || !document.body.contains(drawerHost)) { drawerHost = null; return; }
308
+ const overlay = drawerHost;
309
+ const panel = overlay._panel;
310
+ panel.style.transform = "translateX(100%)";
311
+ overlay.style.opacity = "0";
312
+ window.setTimeout(() => {
313
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
314
+ if (drawerHost === overlay) drawerHost = null;
315
+ }, 300);
316
+ }
317
+ function renderDrawerList(container) {
318
+ container.textContent = "";
319
+ const list = loadNotifyCache();
320
+ if (!list.length) {
321
+ const empty = document.createElement("div");
322
+ empty.textContent = "暂无回合结束消息";
323
+ Object.assign(empty.style, {
324
+ padding: "56px 20px", textAlign: "center",
325
+ color: "rgba(0,0,0,0.42)", fontSize: 13, lineHeight: 1.6,
326
+ });
327
+ container.appendChild(empty);
328
+ return;
329
+ }
330
+ list.forEach((m) => {
331
+ const row = document.createElement("div");
332
+ Object.assign(row.style, {
333
+ display: "flex", alignItems: "center", gap: 12,
334
+ padding: "14px 16px", borderRadius: 12,
335
+ marginBottom: 6, cursor: "default",
336
+ background: "rgba(0,0,0,0)", transition: "background 0.16s ease",
337
+ });
338
+ row.addEventListener("mouseenter", () => { row.style.background = "rgba(0,0,0,0.045)"; });
339
+ row.addEventListener("mouseleave", () => { row.style.background = "rgba(0,0,0,0)"; });
340
+ const info = document.createElement("div");
341
+ Object.assign(info.style, { flex: 1, minWidth: 0 });
342
+ const timeEl = document.createElement("div");
343
+ timeEl.textContent = m.timeText;
344
+ Object.assign(timeEl.style, { fontSize: 13, fontWeight: 600, fontVariantNumeric: "tabular-nums" });
345
+ const subEl = document.createElement("div");
346
+ subEl.textContent = m.title ? m.title + " 已完成" : (m.sessionId ? "会话 " + String(m.sessionId).slice(0, 12) + "…" : "回合结束");
347
+ Object.assign(subEl.style, { fontSize: 12.5, color: "rgba(0,0,0,0.5)", marginTop: 4 });
348
+ info.appendChild(timeEl);
349
+ info.appendChild(subEl);
350
+ const del = document.createElement("button");
351
+ del.type = "button";
352
+ del.textContent = "删除";
353
+ Object.assign(del.style, {
354
+ border: "1px solid rgba(0,0,0,0.14)", background: "transparent",
355
+ borderRadius: 8, padding: "5px 12px", cursor: "pointer",
356
+ fontSize: 12.5, color: "rgba(0,0,0,0.6)", flexShrink: 0,
357
+ transition: "background 0.16s ease, border-color 0.16s ease",
358
+ });
359
+ del.addEventListener("mouseenter", () => {
360
+ del.style.background = "rgba(0,0,0,0.06)";
361
+ del.style.borderColor = "rgba(0,0,0,0.26)";
362
+ });
363
+ del.addEventListener("mouseleave", () => {
364
+ del.style.background = "transparent";
365
+ del.style.borderColor = "rgba(0,0,0,0.14)";
366
+ });
367
+ del.addEventListener("click", (ev) => { ev.stopPropagation(); removeRecord(m.at); });
368
+ row.appendChild(info);
369
+ row.appendChild(del);
370
+ container.appendChild(row);
371
+ });
372
+ }
373
+ function openNotifyDrawer() {
374
+ if (drawerHost && document.body.contains(drawerHost)) { closeNotifyDrawer(); return; }
375
+ const overlay = document.createElement("div");
376
+ Object.assign(overlay.style, {
377
+ position: "fixed", inset: 0, zIndex: 2147483001,
378
+ background: "rgba(0,0,0,0.36)",
379
+ pointerEvents: "auto", opacity: 0,
380
+ transition: "opacity 0.2s ease",
381
+ });
382
+ overlay.addEventListener("click", closeNotifyDrawer);
383
+ const panel = document.createElement("div");
384
+ Object.assign(panel.style, {
385
+ position: "absolute", top: 0, right: 0, height: "100%", width: 400, maxWidth: "94vw",
386
+ background: "rgba(255,255,255,0.94)",
387
+ backdropFilter: "blur(24px) saturate(1.5)",
388
+ WebkitBackdropFilter: "blur(24px) saturate(1.5)",
389
+ boxShadow: "-2px 0 6px rgba(0,0,0,0.04), -10px 0 32px rgba(0,0,0,0.10), -32px 0 80px rgba(0,0,0,0.12)",
390
+ transform: "translateX(100%)",
391
+ transition: "transform 0.26s cubic-bezier(0.16, 1, 0.3, 1)",
392
+ display: "flex", flexDirection: "column",
393
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif",
394
+ color: "#1f1f1f",
395
+ });
396
+ const header = document.createElement("div");
397
+ Object.assign(header.style, {
398
+ display: "flex", alignItems: "center", justifyContent: "space-between",
399
+ padding: "18px 20px 16px", borderBottom: "1px solid rgba(0,0,0,0.07)",
400
+ flexShrink: 0,
401
+ });
402
+ const titleEl = document.createElement("span");
403
+ titleEl.textContent = "回合结束消息";
404
+ Object.assign(titleEl.style, { fontSize: 16, fontWeight: 700, letterSpacing: 0.2 });
405
+ const clearBtn = document.createElement("button");
406
+ clearBtn.type = "button";
407
+ clearBtn.textContent = "全部删除";
408
+ Object.assign(clearBtn.style, {
409
+ border: "1px solid rgba(0,0,0,0.14)", background: "transparent",
410
+ borderRadius: 8, padding: "5px 12px", cursor: "pointer",
411
+ fontSize: 12.5, color: "rgba(0,0,0,0.6)",
412
+ transition: "background 0.16s ease, border-color 0.16s ease",
413
+ });
414
+ clearBtn.addEventListener("mouseenter", () => {
415
+ clearBtn.style.background = "rgba(0,0,0,0.06)";
416
+ clearBtn.style.borderColor = "rgba(0,0,0,0.26)";
417
+ });
418
+ clearBtn.addEventListener("mouseleave", () => {
419
+ clearBtn.style.background = "transparent";
420
+ clearBtn.style.borderColor = "rgba(0,0,0,0.14)";
421
+ });
422
+ clearBtn.addEventListener("click", (ev) => { ev.stopPropagation(); clearAllRecords(); });
423
+ const closeBtn = document.createElement("button");
424
+ closeBtn.type = "button";
425
+ closeBtn.textContent = "×";
426
+ Object.assign(closeBtn.style, {
427
+ border: "none", background: "transparent", cursor: "pointer",
428
+ fontSize: 19, lineHeight: 1, padding: "4px 9px", borderRadius: 8,
429
+ color: "rgba(0,0,0,0.55)", transition: "background 0.15s ease",
430
+ });
431
+ closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(0,0,0,0.06)"; });
432
+ closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; });
433
+ closeBtn.addEventListener("click", (ev) => { ev.stopPropagation(); closeNotifyDrawer(); });
434
+ const headerActions = document.createElement("div");
435
+ Object.assign(headerActions.style, { display: "flex", alignItems: "center", gap: 8 });
436
+ headerActions.appendChild(clearBtn);
437
+ headerActions.appendChild(closeBtn);
438
+ header.appendChild(titleEl);
439
+ header.appendChild(headerActions);
440
+ const listEl = document.createElement("div");
441
+ Object.assign(listEl.style, { flex: 1, overflowY: "auto", padding: "10px 12px 14px" });
442
+ panel.appendChild(header);
443
+ panel.appendChild(listEl);
444
+ overlay.appendChild(panel);
445
+ document.body.appendChild(overlay);
446
+ overlay._panel = panel;
447
+ drawerHost = overlay;
448
+ renderDrawerList(listEl);
449
+ drawerUnsub = subscribeNotify(() => renderDrawerList(listEl));
450
+ void overlay.offsetHeight;
451
+ overlay.style.opacity = "1";
452
+ panel.style.transform = "translateX(0)";
453
+ }
454
+
455
+ // ── 命名空间快照 → 信号检测 → 播放 ------------------------------------------
106
456
  // lastAt:本页面最后响应过的 signal.at。首帧(页面加载时已存在的残留)只做
107
457
  // 基线、不播放;之后 at 严格增长的新 'done' 信号才叮一声。
108
458
  let lastAt = null;
@@ -120,6 +470,14 @@ window.__ModuleLoader__.load({
120
470
  lastAt = at;
121
471
  if (value.enabled !== false) {
122
472
  playDing({ volume: value.volume, freq: value.freq, decayMs: value.decayMs });
473
+ const sessionId = typeof sig.sessionId === "string" ? sig.sessionId : undefined;
474
+ // 信号里只有 sessionId:异步查一次真实标题再落缓存与弹 toast。
475
+ void (async () => {
476
+ const title = await fetchSessionTitle(sessionId);
477
+ const msg = { at, sessionId, title };
478
+ recordTurnEnd(at, sessionId, title);
479
+ showTurnEndToast(msg);
480
+ })();
123
481
  }
124
482
  }
125
483