diff --git a/README.md b/README.md index bbc0f46..62c2ff7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # dsh-notification -Desktop notifications for the DeepSeek Harness web GUI. When a session finishes a turn, the browser shows a system notification (via the `Notification` API), so you can switch tabs and still know when DSH is done. Per-outcome toggles and include/exclude keyword rules control exactly which completions notify. +Desktop notifications for the DeepSeek Harness web GUI. When a session finishes a turn, the browser shows a system notification (via the `Notification` API), so you can switch tabs and still know when DSH is done; it also notifies when a session waits for your **approval, an answer, or a plan review**, so a pending confirmation is never missed on another tab. Per-outcome toggles, pending-kind toggles, and include/exclude keyword rules control exactly which messages notify. -No harness change is needed: the host contributes a session projection (a bounded summary of each session's last completed turn), and the client watches the session list's completion reminder and applies its own persisted preferences. +No harness change is needed: the host contributes a session projection (a bounded summary of each session's last completed turn), and the client watches the session list's completion reminder and `pendingInteraction` (blocking-wait) signal, applying its own persisted preferences. ``` host: notification projection (last turn's reason/text/tools) --session/projection--> browser -client: session list completion reminder (live, dedup) + persisted settings +client: session list completion reminder (live, dedup) + pendingInteraction (approval/question/plan-review) + + persisted settings -> permission + current-session visibility gate -> new Notification("DSH finished", { body: "deploy done" }) + -> new Notification("DSH needs your approval", { body: "" }) ``` ## Install @@ -27,6 +29,7 @@ The settings section lives under **Settings > Notifications**. | --- | --- | --- | | Enable notifications | on | Master switch; off stops every notification while keeping rules. | | Notify on completed / error / aborted / blocked / token limit | completed + error on, rest off | Which turn-end reasons notify (the host projection reports the reason). | +| Awaiting approval / awaiting your answer / awaiting plan review | approval + question on, plan review off | Which blocking-wait states notify, from the session list's `pendingInteraction` signal (the same fact behind the sidebar's amber dot). | | Keyword rules | none | Include/exclude filters matched against the session title, the turn's reply text, and its tool names. Include rules: at least one must match. Exclude rules: a match suppresses. Rules support literal or regex matching with an optional case-sensitive flag. | | Require manual dismiss | off | The notification stays until dismissed. | | Only notify when the task is out of view | on | Suppress a notification only when its session is currently in view. A completion still notifies while the page is hidden or while another session/workspace is open. Turn it off to notify even for the session being watched. Notifications for the same session replace each other. | @@ -56,7 +59,7 @@ Host-side tunables live on the plugin row in `cordis.yml`: ## Permission boundary - The host folds a pure projection over the session log (turn reason, bounded reply text, tool names) and the projection seam delivers it to the browser; the plugin writes nothing to the log and registers no model-facing tools. -- The client watches the session list's completion reminder (a live "finished while not selected" edge the runtime already computes) and shows a notification only when the user has granted Notification permission. +- The client watches the session list's completion reminder (a live "finished while not selected" edge the runtime already computes) and blocking-wait signal (a session's `pendingInteraction` status edge), showing a notification only when the user has granted Notification permission. - Rule matching runs client-side against the projected content; the reply body never exceeds `maxBodyChars`. ## Development @@ -74,6 +77,8 @@ The repo expects the harness checkout at `../dsh` for the dev-time `link:` resol - Notifications require the page to be open (the browser shows them while it is hidden, but not after the tab is closed) and Notification permission granted; a denied site permission cannot be overridden from inside the page. - Notifications fire once per finished turn (a running→idle edge on any session); a completion that happened while the page was disconnected is not re-notified on reconnect. +- Pending-wait notifications fire once when a session's `pendingInteraction` transitions from none to approval/question/plan-review (deduplicated per session); a wait that appeared while disconnected is not re-notified. +- A pending-wait notification body is the session title — not the specific tool name or reason, because the session list exposes only the status enum, not `toolName`/`reason`. - The rule subject is the session title plus the last turn's reply text and tool names — earlier turns are not matched. - Notification body is a flat text snippet; the click action only focuses the window (no deep link to the turn). diff --git a/README.zh.md b/README.zh.md index 71741ad..ffc645d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,14 +1,16 @@ # dsh-notification -DeepSeek Harness Web GUI 的桌面通知插件。当会话结束一轮任务时,浏览器通过系统 `Notification` API 弹出通知,让你切到别的标签页也能知道 DSH 已经完成。按结束状态开关 + 关键词包含/排除规则,精确控制哪些完成要提醒。 +DeepSeek Harness Web GUI 的桌面通知插件。当会话结束一轮任务时,浏览器通过系统 `Notification` API 弹出通知,让你切到别的标签页也能知道 DSH 已经完成;当会话等待你**审批、回答提问或评审计划**时也会弹通知,切到别的标签页也不会错过需要确认的操作。按结束状态开关 + 待确认类型开关 + 关键词包含/排除规则,精确控制哪些消息要提醒。 -**无需改动 harness**:host 侧贡献一个会话投影(每个会话最近完成一轮的有界摘要),client 侧监听会话列表的完成提醒,并应用自己持久化的偏好设置。 +**无需改动 harness**:host 侧贡献一个会话投影(每个会话最近完成一轮的有界摘要),client 侧监听会话列表的完成提醒与 `pendingInteraction`(等待确认)信号,并应用自己持久化的偏好设置。 ``` host: notification 投影(最近一轮的原因/正文/工具名) --session/projection--> 浏览器 -client: 会话列表完成提醒(实时、去重)+ 持久化设置 +client: 会话列表完成提醒(实时、去重)+ pendingInteraction(审批/提问/计划评审) + + 持久化设置 -> 权限 + 当前会话可见性门控 -> new Notification("DSH 已完成", { body: "部署完成" }) + -> new Notification("DSH 需要你的审批", { body: "<会话标题>" }) ``` ## 安装 @@ -27,6 +29,7 @@ dsh plugin --profile web add https://github.com/omdsh-dev/dsh-notification/archi | --- | --- | --- | | 启用通知 | 开 | 总开关;关闭后不再弹出,规则与偏好保留。 | | 正常完成 / 出错 / 中止 / 阻塞 / 达 Token 上限 | 完成 + 出错开,其余关 | 哪些结束状态触发通知(host 投影会报告结束原因)。 | +| 等待审批 / 等待回答提问 / 等待计划评审 | 审批 + 提问开,计划评审关 | 哪些「等待确认」状态触发通知(来自会话列表的 `pendingInteraction` 信号,即侧边栏琥珀色圆点的数据源)。 | | 关键词规则 | 无 | 针对会话标题、该轮回复文本与调用过的工具名做包含/排除匹配。包含规则:至少命中一条才通知;排除规则:命中即不通知。支持字面量或正则,可区分大小写。 | | 需要手动关闭 | 关 | 通知保持显示直到手动关闭。 | | 仅在任务不在眼前时通知 | 开 | 只有完成任务所属会话正显示在眼前时才不提醒;页面在后台,或正在查看其他会话、其他工作区时仍会提醒。关闭后,即使正在观看该会话也会通知。同一会话的通知会互相替换。 | @@ -56,7 +59,7 @@ Host 侧可调参数在 `cordis.yml` 的插件行上: ## 权限边界 - host 侧对会话日志做纯投影折叠(轮次原因、有界的回复文本、工具名),由投影通道交付给浏览器;插件不写日志,不注册面向模型的工具。 -- client 侧监听会话列表的完成提醒(运行时已计算的"未选中会话已完成"实时去重信号),仅在用户授予 Notification 权限后弹通知。 +- client 侧监听会话列表的完成提醒(运行时已计算的"未选中会话已完成"实时去重信号)与等待确认信号(会话的 `pendingInteraction` 状态边沿),仅在用户授予 Notification 权限后弹通知。 - 规则匹配在 client 侧针对投影内容进行;回复正文不超过 `maxBodyChars`。 ## 开发 @@ -74,6 +77,8 @@ pnpm run build # esbuild host/client/invariant 打包 + tsc 声明 - 通知需要页面处于打开状态(隐藏时可弹,但关闭标签页后不再弹),且需授予 Notification 权限;站点权限被拒后无法从页面内恢复。 - 通知在每轮结束(任意会话的 running→idle 边沿)触发一次;断线期间完成的轮次在重连后不会补发。 +- 等待确认通知在会话的 `pendingInteraction` 状态由「无」变为「审批/提问/计划评审」时触发一次(同一会话去重、按会话替换);断线期间出现的等待不会补发。 +- 等待确认通知的正文是会话标题,不含具体工具名/原因(会话列表只暴露状态枚举,未暴露 `toolName`/`reason`)。 - 规则匹配对象为会话标题 + 最近一轮的回复文本与工具名,不匹配更早的轮次。 - 通知正文是纯文本摘要;点击仅聚焦窗口(不深链到具体轮次)。 diff --git a/lib/client.js b/lib/client.js index 35aec5b..c7deefd 100644 --- a/lib/client.js +++ b/lib/client.js @@ -44,6 +44,16 @@ function titleKey(reason) { return "notify.titleMaxTokens"; } } +function pendingTitleKey(kind) { + switch (kind) { + case "approval": + return "notify.titleApproval"; + case "question": + return "notify.titleQuestion"; + case "plan-review": + return "notify.titlePlanReview"; + } +} function bodyText(body, emptyBody) { const trimmed = body.trim(); return trimmed === "" ? emptyBody : trimmed; @@ -56,6 +66,9 @@ function shouldShow(permission, backgroundOnly, documentHidden, completedSession function notificationTag(sessionId) { return `dsh-notification-${sessionId}`; } +function pendingNotificationTag(sessionId) { + return `dsh-notification-pending-${sessionId}`; +} function notificationsApi() { return typeof Notification === "undefined" ? void 0 : Notification; } @@ -101,6 +114,11 @@ var OUTCOMES = [ { field: "notifyBlocked", key: "settings.when.blocked", defaultValue: false }, { field: "notifyMaxTokens", key: "settings.when.maxTokens", defaultValue: false } ]; +var PENDING = [ + { field: "notifyApproval", key: "settings.pending.approval", defaultValue: true }, + { field: "notifyQuestion", key: "settings.pending.question", defaultValue: true }, + { field: "notifyPlanReview", key: "settings.pending.planReview", defaultValue: false } +]; function notifyPatch(field, checked) { return { [field]: checked }; } @@ -276,6 +294,23 @@ function NotificationSettingsSection({ useSettings, set, requestPermission, send field )) }) ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh_notification_card", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh_notification_cardTitle", children: t("settings.pending.title") }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh_notification_cardDesc", children: t("settings.pending.subtitle") }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh_notification_grid", children: PENDING.map(({ field, key, defaultValue }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + Toggle, + { + defaultChecked: settings?.[field] ?? defaultValue, + label: t(key), + onChange: (checked) => { + set(notifyPatch(field, checked)); + } + }, + field + )) }) + ] }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh_notification_card", children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh_notification_cardTitle", children: t("settings.rules.title") }), @@ -362,6 +397,11 @@ var zh = { "settings.when.aborted": "\u88AB\u4E2D\u6B62", "settings.when.blocked": "\u88AB\u963B\u585E", "settings.when.maxTokens": "\u8FBE\u5230 Token \u4E0A\u9650", + "settings.pending.title": "\u7B49\u5F85\u786E\u8BA4", + "settings.pending.subtitle": "\u5F53 DSH \u7B49\u5F85\u4F60\u5BA1\u6279\u6216\u56DE\u7B54\u63D0\u95EE\u65F6\u63D0\u9192\uFF0C\u5207\u5230\u5176\u4ED6\u6807\u7B7E\u9875\u4E5F\u4E0D\u4F1A\u9519\u8FC7\u3002", + "settings.pending.approval": "\u7B49\u5F85\u5BA1\u6279", + "settings.pending.question": "\u7B49\u5F85\u56DE\u7B54\u63D0\u95EE", + "settings.pending.planReview": "\u7B49\u5F85\u8BA1\u5212\u8BC4\u5BA1", "settings.rules.title": "\u5173\u952E\u8BCD\u89C4\u5219", "settings.rules.subtitle": "\u89C4\u5219\u5339\u914D\u8BE5\u8F6E\u56DE\u590D\u6587\u672C\u4E0E\u8C03\u7528\u8FC7\u7684\u5DE5\u5177\u540D\u3002\u5305\u542B\u89C4\u5219\uFF1A\u547D\u4E2D\u4EFB\u4E00\u624D\u901A\u77E5\uFF1B\u6392\u9664\u89C4\u5219\uFF1A\u547D\u4E2D\u5373\u4E0D\u901A\u77E5\u3002", "settings.rules.empty": "\u6682\u65E0\u89C4\u5219\uFF0C\u6240\u6709\u5DF2\u542F\u7528\u7684\u5B8C\u6210\u72B6\u6001\u90FD\u4F1A\u901A\u77E5\u3002", @@ -387,6 +427,10 @@ var zh = { "notify.titleAborted": "DSH \u5DF2\u4E2D\u6B62", "notify.titleBlocked": "DSH \u9700\u8981\u5904\u7406", "notify.titleMaxTokens": "DSH \u8FBE\u5230 Token \u4E0A\u9650", + "notify.titleApproval": "DSH \u9700\u8981\u4F60\u7684\u5BA1\u6279", + "notify.titleQuestion": "DSH \u9700\u8981\u4F60\u56DE\u7B54\u95EE\u9898", + "notify.titlePlanReview": "DSH \u9700\u8981\u4F60\u8BC4\u5BA1\u8BA1\u5212", + "notify.pendingBodyFallback": "\u6709\u5F85\u5904\u7406\u7684\u64CD\u4F5C", "notify.emptyBody": "\u4EFB\u52A1\u5DF2\u5B8C\u6210", "notify.testTitle": "DSH \u901A\u77E5\u6D4B\u8BD5", "notify.testBody": "\u5982\u679C\u4F60\u770B\u5230\u8FD9\u6761\u901A\u77E5\uFF0C\u8BF4\u660E\u901A\u77E5\u5DF2\u914D\u7F6E\u6210\u529F\u3002" @@ -411,6 +455,11 @@ var en = { "settings.when.aborted": "Aborted", "settings.when.blocked": "Blocked", "settings.when.maxTokens": "Hit token limit", + "settings.pending.title": "Awaiting confirmation", + "settings.pending.subtitle": "Get notified when DSH waits for your approval or an answer, even while on another tab.", + "settings.pending.approval": "Awaiting approval", + "settings.pending.question": "Awaiting your answer", + "settings.pending.planReview": "Awaiting plan review", "settings.rules.title": "Keyword rules", "settings.rules.subtitle": "Rules match the turn's reply text and called tool names. Include rules: notify only if one matches. Exclude rules: suppress on match.", "settings.rules.empty": "No rules yet \u2014 every enabled end state notifies.", @@ -436,6 +485,10 @@ var en = { "notify.titleAborted": "DSH aborted", "notify.titleBlocked": "DSH needs attention", "notify.titleMaxTokens": "DSH hit the token limit", + "notify.titleApproval": "DSH needs your approval", + "notify.titleQuestion": "DSH needs your answer", + "notify.titlePlanReview": "DSH needs your plan review", + "notify.pendingBodyFallback": "There is a pending action", "notify.emptyBody": "The task is done", "notify.testTitle": "DSH notification test", "notify.testBody": "If you can see this notification, notifications are configured correctly." @@ -707,12 +760,16 @@ function defaultNotificationSettings() { notifyAborted: false, notifyBlocked: false, notifyMaxTokens: false, + notifyApproval: true, + notifyQuestion: true, + notifyPlanReview: false, rules: [], requireInteraction: false, backgroundOnly: true }; } var V2_PERSIST_KEY = "dsh-notification.v2"; +var V3_PERSIST_KEY = "dsh-notification.v3"; function migrateV2Settings(storage) { const target = storage ?? (typeof localStorage === "undefined" ? void 0 : localStorage); if (target === void 0) return void 0; @@ -726,9 +783,22 @@ function migrateV2Settings(storage) { return void 0; } } +function migrateV3Settings(storage) { + const target = storage ?? (typeof localStorage === "undefined" ? void 0 : localStorage); + if (target === void 0) return void 0; + try { + const raw = target.getItem(V3_PERSIST_KEY); + if (raw === null) return void 0; + target.removeItem(V3_PERSIST_KEY); + const saved = JSON.parse(raw); + return { ...defaultNotificationSettings(), ...saved }; + } catch { + return void 0; + } +} function createNotificationSettingsStore() { - return (0, import_client.createSnapshotStore)(migrateV2Settings() ?? defaultNotificationSettings(), { - persist: { name: "dsh-notification.v3" } + return (0, import_client.createSnapshotStore)(migrateV3Settings() ?? migrateV2Settings() ?? defaultNotificationSettings(), { + persist: { name: "dsh-notification.v4" } }); } @@ -759,6 +829,16 @@ function reasonEnabled(settings, reason) { return settings.notifyMaxTokens; } } +function pendingReasonEnabled(settings, kind) { + switch (kind) { + case "approval": + return settings.notifyApproval; + case "question": + return settings.notifyQuestion; + case "plan-review": + return settings.notifyPlanReview; + } +} function ruleSubject(title, body, tools) { const parts = []; if (title !== void 0 && title.trim() !== "") parts.push(title); @@ -806,6 +886,21 @@ function notificationFor(sessionId, origin, title, projection, settings) { tag: notificationTag(sessionId) }; } +function pendingAdvance(prev, kind) { + if (prev === void 0) return { kind, fresh: false }; + const fresh = kind !== void 0 && kind !== prev.kind; + return { kind, fresh }; +} +function pendingNotificationFor(sessionId, origin, title, kind, settings) { + if (origin === "subagent") return null; + if (!settings.enabled) return null; + if (!pendingReasonEnabled(settings, kind)) return null; + return { + kind, + body: title?.trim() ?? "", + tag: pendingNotificationTag(sessionId) + }; +} // src/client/index.ts var inject = ["sessions", "slots", "locale"]; @@ -877,6 +972,50 @@ function apply(ctx) { stopReset(); }; }, "dsh-notification: completion runner"); + ctx.effect(() => { + const observedPending = /* @__PURE__ */ new Map(); + const reseed = () => { + observedPending.clear(); + }; + const stopReset = ctx.on("connection/reset", reseed); + const off = sessions.list.subscribe(() => { + const state = sessions.list.getSnapshot(); + const current = settings.getSnapshot(); + for (const id of state.ids) { + const summary = state.byId[id]; + const kind = summary.pendingInteraction; + const { kind: nextKind, fresh } = pendingAdvance(observedPending.get(id), kind); + observedPending.set(id, { kind: nextKind }); + if (!fresh || nextKind === void 0) continue; + const plan = pendingNotificationFor(summary.id, summary.origin, summary.displayTitle, nextKind, current); + if (plan === null) { + console.info(`[dsh-notification] pending ${nextKind} ${id} suppressed by settings`); + continue; + } + const permission = notificationsApi()?.permission ?? "denied"; + const showIt = shouldShow(permission, current.backgroundOnly, document.hidden, id, state.current); + console.info( + `[dsh-notification] pending ${nextKind} ${id}: show=${showIt} (permission=${permission} backgroundOnly=${current.backgroundOnly} hidden=${document.hidden} current=${String(state.current)})` + ); + if (showIt) { + show( + t(pendingTitleKey(nextKind)), + bodyText(plan.body, t("notify.pendingBodyFallback")), + plan.tag, + current.requireInteraction + ); + } + } + const live = new Set(state.ids); + for (const id of [...observedPending.keys()]) { + if (!live.has(id)) observedPending.delete(id); + } + }); + return () => { + off(); + stopReset(); + }; + }, "dsh-notification: pending runner"); ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "notification", diff --git a/lib/client.js.map b/lib/client.js.map index f77ac54..8dafd18 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../src/client/index.ts", "../src/client/SettingsSection.tsx", "../src/client/notifier.ts", "../src/client/rules.ts", "../src/client/locales.ts", "../src/client/styles.ts", "../src/client/store.ts", "../src/client/decision.ts", "../src/client/runner.ts"], - "sourcesContent": ["/**\n * dsh-notification client plugin: the browser half of the completion\n * notification. Persists the notification preferences in a local snapshot\n * store, watches the session list for a running\u2192idle edge (a live \"a session\n * finished\" signal), reads the host `notification` and `title` projections\n * for the turn's reason/text/tools, and \u2014 when permission and the\n * background-only gate pass \u2014 shows a desktop notification. Also registers the\n * settings section and the locale dictionaries. No harness allowlist is touched.\n */\nimport type { ClientContext, SessionListState, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'\n// Type-only: the ctx.locale Context merge.\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: the settings.section SlotMap entry.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { NotificationSettings } from '../contract.ts'\nimport { NotificationSettingsSection, type NotificationSectionInjected } from './SettingsSection.tsx'\nimport { NS, en, zh } from './locales.ts'\nimport { adoptStyles } from './styles.ts'\nimport { createNotificationSettingsStore } from './store.ts'\nimport { notificationFor, projectionAdvance } from './runner.ts'\nimport { bodyText, notificationsApi, shouldShow, titleKey } from './notifier.ts'\n\n/** Required services: the session list, slots, and locale. */\nexport const inject = ['sessions', 'slots', 'locale']\n\n/** The slice of the sessions service this plugin reads. */\ninterface SessionsListFace {\n readonly list: { getSnapshot(): SessionListState; subscribe(listener: () => void): () => void }\n}\n\n/**\n * Compose the notification surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n adoptStyles()\n console.info('[dsh-notification] bundle loaded (edge trigger, settings v2)')\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-notification: dictionaries')\n\n const t = ctx.locale.bind(NS)\n // The client sessions face is read through the service store, not the\n // `ctx.sessions` property proxy: the host dsh-session package merges a\n // different `sessions` Context member, and the two collide in this\n // single-program build.\n const sessions = ctx.get('sessions') as unknown as SessionsListFace\n const settings: SnapshotStore = createNotificationSettingsStore()\n const set = (patch: Partial): void => {\n settings.update(draft => { Object.assign(draft, patch) })\n }\n const requestPermission = (): Promise =>\n notificationsApi()?.requestPermission() ?? Promise.resolve('denied')\n\n const show = (title: string, body: string, tag: string, requireInteraction: boolean): void => {\n const api = notificationsApi()\n if (api === undefined || api.permission !== 'granted') return\n const notification = new api(title, { body, tag, requireInteraction })\n notification.onclick = () => { window.focus() }\n }\n const sendTest = (): void => {\n show(t('notify.testTitle'), t('notify.testBody'), 'dsh-notification-test', false)\n }\n\n // Completion runner: the host projection's turn is monotonic per session,\n // so an advance past the last-observed turn IS a freshly completed turn with\n // its own correct body \u2014 no race with the session-status frame. The first\n // observation seeds the baseline (history is never re-notified), and a\n // reconnect re-seeds so a completion that happened while disconnected\n // never fires.\n ctx.effect(() => {\n const observedTurn = new Map()\n const reseed = (): void => { observedTurn.clear() }\n const stopReset = ctx.on('connection/reset', reseed)\n const off = sessions.list.subscribe(() => {\n const state = sessions.list.getSnapshot()\n const current = settings.getSnapshot()\n for (const id of state.ids) {\n const summary = state.byId[id]\n const projection = summary.projectionValues?.notification\n const { nextTurn, fresh } = projectionAdvance(observedTurn.get(id), projection)\n observedTurn.set(id, nextTurn)\n if (!fresh) continue\n const plan = notificationFor(summary.id, summary.origin, summary.title, projection, current)\n if (plan === null) {\n console.info(`[dsh-notification] turn ${nextTurn} ${id} suppressed by settings/rules`)\n continue\n }\n const permission = notificationsApi()?.permission ?? 'denied'\n const showIt = shouldShow(permission, current.backgroundOnly, document.hidden, id, state.current)\n console.info(\n `[dsh-notification] turn ${nextTurn} ${id}: reason=${plan.reason} show=${showIt}`\n + ` (permission=${permission} backgroundOnly=${current.backgroundOnly}`\n + ` hidden=${document.hidden} current=${String(state.current)})`,\n )\n if (showIt) {\n show(\n t(titleKey(plan.reason)),\n bodyText(plan.body, t('notify.emptyBody')),\n plan.tag,\n current.requireInteraction,\n )\n }\n }\n const live = new Set(state.ids)\n for (const id of [...observedTurn.keys()]) {\n if (!live.has(id)) observedTurn.delete(id)\n }\n })\n return () => { off(); stopReset() }\n }, 'dsh-notification: completion runner')\n\n // The settings section: master switch, permission card, outcome toggles, rules, advanced.\n ctx.slots.inject('settings.section', () => ctx.slots.register({\n name: 'settings.section',\n id: 'notification',\n order: 60,\n label: () => t('nav'),\n locale: NS,\n inject: (): NotificationSectionInjected => ({\n hooks: { settings },\n set,\n requestPermission,\n sendTest,\n }),\n }, NotificationSettingsSection))\n}\n", "/**\n * The settings page section for the notification preferences: master switch,\n * browser-permission card (grant + test), per-outcome toggles, the include/\n * exclude keyword-rule editor, and the advanced options. Preferences live in\n * the client-persisted snapshot store; boolean switches are native checkboxes\n * (uncontrolled \u2014 they flip instantly and persist on change); the rule list is\n * a local draft persisted as one array on save, so an in-progress\n * (empty-pattern) rule never reaches the store.\n */\nimport { useState } from 'react'\nimport type { PropsLocale, PropsRuntime, InjectFace } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { NotificationRule, NotificationSettings } from '../contract.ts'\nimport type { NotificationKey } from './locales.ts'\nimport { notificationsApi } from './notifier.ts'\nimport { emptyRule, firstRuleError, patchRule, removeRule } from './rules.ts'\n\n/** The per-outcome toggle fields. */\ntype NotifyField = 'notifyCompleted' | 'notifyError' | 'notifyAborted' | 'notifyBlocked' | 'notifyMaxTokens'\n\n/** Injected business face: the live settings store (bound to `useSettings`), the write verb, and the permission/test verbs. */\nexport interface NotificationSectionInjected {\n hooks: { settings: SnapshotStore }\n set: (patch: Partial) => void\n requestPermission: () => Promise\n sendTest: () => void\n}\n\n/** Full section props: runtime share + injected face + the locale seat. */\nexport type NotificationSectionProps = PropsRuntime<'settings.section'> & InjectFace & PropsLocale<'notification'>\n\n/** One per-outcome toggle's durable field, copy key, and store default. */\nconst OUTCOMES: ReadonlyArray<{ field: NotifyField; key: NotificationKey; defaultValue: boolean }> = [\n { field: 'notifyCompleted', key: 'settings.when.completed', defaultValue: true },\n { field: 'notifyError', key: 'settings.when.error', defaultValue: true },\n { field: 'notifyAborted', key: 'settings.when.aborted', defaultValue: false },\n { field: 'notifyBlocked', key: 'settings.when.blocked', defaultValue: false },\n { field: 'notifyMaxTokens', key: 'settings.when.maxTokens', defaultValue: false },\n]\n\n/** A single-outcome-toggle patch. */\nfunction notifyPatch(field: NotifyField, checked: boolean): Partial {\n return { [field]: checked } as Partial\n}\n\n/** One native-checkbox preference row. */\nfunction Toggle(props: {\n defaultChecked: boolean\n label: string\n desc?: string\n onChange: (checked: boolean) => void\n}): JSX.Element {\n return (\n \n )\n}\n\n/** One editable include/exclude rule row. */\nfunction RuleRow(props: {\n rule: NotificationRule\n errorKey?: NotificationKey\n autoFocus: boolean\n t: (key: NotificationKey) => string\n onPatch: (patch: Partial) => void\n onRemove: () => void\n}): JSX.Element {\n const { rule, t } = props\n return (\n
\n { props.onPatch({ mode: event.target.value === 'exclude' ? 'exclude' : 'include' }) }}\n >\n \n \n \n { props.onPatch({ pattern: event.target.value }) }}\n />\n \n \n \n \n \n {props.errorKey === undefined ? null : {t(props.errorKey)}}\n
\n )\n}\n\n/**\n * Render the section.\n * @param props - runtime share, the bound settings hook, the injected verbs, and `t`.\n * @returns the section element tree.\n */\nexport function NotificationSettingsSection({ useSettings, set, requestPermission, sendTest, t }: NotificationSectionProps) {\n const settings = useSettings(snapshot => snapshot)\n const [permission, setPermission] = useState(() => notificationsApi()?.permission ?? 'denied')\n const [draft, setDraft] = useState(null)\n const [focusedRuleId, setFocusedRuleId] = useState(null)\n\n const durable = settings?.rules ?? []\n const rules = draft ?? durable\n const dirty = draft !== null\n const error = firstRuleError(rules)\n\n const edit = (updater: (current: NotificationRule[]) => NotificationRule[]): void => {\n setDraft(updater(draft ?? durable))\n }\n const addRule = (): void => {\n const rule = emptyRule()\n edit(list => [...list, rule])\n setFocusedRuleId(rule.id)\n }\n const saveRules = (): void => {\n if (draft === null) return\n set({ rules: draft })\n setDraft(null)\n setFocusedRuleId(null)\n }\n const onRequestPermission = async (): Promise => {\n setPermission(await requestPermission())\n }\n\n const permissionText = t(`settings.permission.${permission}`)\n const badgeClass = permission === 'granted'\n ? 'dsh_notification_badgeGranted'\n : permission === 'denied' ? 'dsh_notification_badgeDenied' : 'dsh_notification_badgeDefault'\n\n return (\n
\n
\n

{t('settings.title')}

\n

{t('settings.subtitle')}

\n
\n\n
\n { set({ enabled: checked }) }}\n />\n
\n\n
\n
\n
{t('settings.permission.title')}
\n
{t('settings.permission.desc')}
\n
\n
\n {permissionText}\n \n \n {t('settings.permission.test')}\n \n
\n
\n\n
\n
\n
{t('settings.when.title')}
\n
{t('settings.when.subtitle')}
\n
\n
\n {OUTCOMES.map(({ field, key, defaultValue }) => (\n { set(notifyPatch(field, checked)) }}\n />\n ))}\n
\n
\n\n
\n
\n
{t('settings.rules.title')}
\n
{t('settings.rules.subtitle')}
\n
\n {rules.length === 0\n ?
{t('settings.rules.empty')}
\n : (\n
\n {rules.map((rule, index) => (\n { edit(list => patchRule(list, rule.id, patch)) }}\n onRemove={() => { edit(list => removeRule(list, rule.id)) }}\n />\n ))}\n
\n )}\n
\n \n \n {t('settings.rules.save')}\n \n {error !== undefined\n ? {t(error.key)}\n : dirty ? {t('settings.rules.unsaved')} : null}\n
\n
\n\n
\n
{t('settings.advanced.title')}
\n { set({ requireInteraction: checked }) }}\n />\n { set({ backgroundOnly: checked }) }}\n />\n
\n
\n )\n}\n", "/**\n * Client-side notification rendering: the pure parts (title/body/gating) split\n * out for unit tests, while the `Notification` construction stays in the thin\n * runner wired by the plugin body.\n */\nimport type { NotificationReason } from '../contract.ts'\n\n/** The reason title key for one turn-end reason. */\nexport function titleKey(reason: NotificationReason): 'notify.titleCompleted' | 'notify.titleError' | 'notify.titleAborted' | 'notify.titleBlocked' | 'notify.titleMaxTokens' {\n switch (reason) {\n case 'completed': return 'notify.titleCompleted'\n case 'error': return 'notify.titleError'\n case 'aborted': return 'notify.titleAborted'\n case 'blocked': return 'notify.titleBlocked'\n case 'max-tokens': return 'notify.titleMaxTokens'\n }\n}\n\n/** The notification body: the reply snippet, or the empty-body fallback. */\nexport function bodyText(body: string, emptyBody: string): string {\n const trimmed = body.trim()\n return trimmed === '' ? emptyBody : trimmed\n}\n\n/**\n * Whether a completion should surface a desktop notification, given the browser\n * permission, the background-only preference, page visibility, and whether\n * the completed session is the one currently in view.\n */\nexport function shouldShow(\n permission: NotificationPermission,\n backgroundOnly: boolean,\n documentHidden: boolean,\n completedSessionId?: string,\n currentSessionId?: string,\n): boolean {\n if (permission !== 'granted') return false\n if (backgroundOnly && !documentHidden && completedSessionId === currentSessionId) return false\n return true\n}\n\n/** The grouping tag: one notification slot per session. */\nexport function notificationTag(sessionId: string): string {\n return `dsh-notification-${sessionId}`\n}\n\n/** The surface this code may show notifications on (absent in insecure contexts). */\nexport function notificationsApi(): typeof Notification | undefined {\n return typeof Notification === 'undefined' ? undefined : Notification\n}\n", "/**\n * Client-side rule-editing helpers: minting, a draft validator, and the\n * field-update fold. Pure and unit-tested; the settings section consumes them.\n */\nimport type { NotificationRule } from '../contract.ts'\nimport type { NotificationKey } from './locales.ts'\n\n/** Mint a fresh rule id (browser crypto). */\nexport function mintRuleId(): string {\n return crypto.randomUUID()\n}\n\n/** A new empty include rule ready for editing. */\nexport function emptyRule(): NotificationRule {\n return { id: mintRuleId(), enabled: true, mode: 'include', pattern: '', isRegex: false, caseSensitive: false }\n}\n\n/**\n * Validate one draft rule and return the blocking reason, or undefined when\n * valid. Mirrors the Host's write-time validator so a rule that cannot persist\n * is caught before the save button is enabled.\n * @param rule - the draft rule.\n * @returns a reason string, or undefined when valid.\n */\nexport function ruleError(rule: NotificationRule): NotificationKey | undefined {\n if (rule.pattern.trim() === '') return 'settings.rules.invalid'\n if (rule.isRegex) {\n try {\n new RegExp(rule.pattern)\n } catch {\n return 'settings.rules.invalidRegex'\n }\n }\n return undefined\n}\n\n/** First invalid rule in a draft list, or undefined when every rule is valid. */\nexport function firstRuleError(rules: readonly NotificationRule[]): { index: number; key: NotificationKey } | undefined {\n for (let index = 0; index < rules.length; index++) {\n const key = ruleError(rules[index] as NotificationRule)\n if (key !== undefined) return { index, key }\n }\n return undefined\n}\n\n/**\n * Replace one rule by id, returning a new array (immutable update).\n * @param rules - the draft list.\n * @param id - the rule to replace.\n * @param patch - the fields to merge over the rule.\n * @returns the updated list.\n */\nexport function patchRule(rules: readonly NotificationRule[], id: string, patch: Partial): NotificationRule[] {\n return rules.map(rule => (rule.id === id ? { ...rule, ...patch } : rule))\n}\n\n/** Remove one rule by id, returning a new array. */\nexport function removeRule(rules: readonly NotificationRule[], id: string): NotificationRule[] {\n return rules.filter(rule => rule.id !== id)\n}\n", "/**\n * `notification` locale namespace: the settings-section copy and the desktop\n * notification titles/bodies. Chinese is the product copy; English mirrors it.\n */\n\n/** Simplified Chinese dictionary (the key-set source of truth). */\nexport const zh = {\n 'nav': '\u901A\u77E5',\n 'settings.title': '\u4EFB\u52A1\u5B8C\u6210\u901A\u77E5',\n 'settings.subtitle': '\u5F53 DSH \u5B8C\u6210\u4E00\u6B21\u64CD\u4F5C\u65F6\uFF0C\u901A\u8FC7\u6D4F\u89C8\u5668\u7CFB\u7EDF\u901A\u77E5\u63D0\u9192\u4F60\uFF1B\u53EF\u4EE5\u7528\u5173\u952E\u8BCD\u89C4\u5219\u7CBE\u786E\u63A7\u5236\u54EA\u4E9B\u6D88\u606F\u9700\u8981\u63D0\u9192\u3002',\n 'settings.enabled': '\u542F\u7528\u901A\u77E5',\n 'settings.enabledDesc': '\u5173\u95ED\u540E\u4E0D\u4F1A\u5F39\u51FA\u4EFB\u4F55\u901A\u77E5\uFF0C\u89C4\u5219\u4E0E\u504F\u597D\u8BBE\u7F6E\u4ECD\u4F1A\u4FDD\u7559\u3002',\n 'settings.permission.title': '\u6D4F\u89C8\u5668\u6743\u9650',\n 'settings.permission.desc': '\u901A\u77E5\u9700\u8981\u6D4F\u89C8\u5668\u6388\u6743\u3002\u70B9\u51FB\u4E0B\u65B9\u6309\u94AE\u6388\u6743\uFF0C\u7136\u540E\u53D1\u9001\u4E00\u6761\u6D4B\u8BD5\u901A\u77E5\u786E\u8BA4\u751F\u6548\u3002',\n 'settings.permission.granted': '\u5DF2\u6388\u6743',\n 'settings.permission.denied': '\u5DF2\u62D2\u7EDD\uFF08\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u7684\u7AD9\u70B9\u8BBE\u7F6E\u4E2D\u91CD\u65B0\u5F00\u542F\uFF09',\n 'settings.permission.default': '\u672A\u6388\u6743',\n 'settings.permission.request': '\u8BF7\u6C42\u901A\u77E5\u6743\u9650',\n 'settings.permission.test': '\u53D1\u9001\u6D4B\u8BD5\u901A\u77E5',\n 'settings.when.title': '\u901A\u77E5\u65F6\u673A',\n 'settings.when.subtitle': '\u9009\u62E9\u54EA\u4E9B\u7ED3\u675F\u72B6\u6001\u89E6\u53D1\u901A\u77E5\u3002',\n 'settings.when.completed': '\u6B63\u5E38\u5B8C\u6210',\n 'settings.when.error': '\u51FA\u9519',\n 'settings.when.aborted': '\u88AB\u4E2D\u6B62',\n 'settings.when.blocked': '\u88AB\u963B\u585E',\n 'settings.when.maxTokens': '\u8FBE\u5230 Token \u4E0A\u9650',\n 'settings.rules.title': '\u5173\u952E\u8BCD\u89C4\u5219',\n 'settings.rules.subtitle': '\u89C4\u5219\u5339\u914D\u8BE5\u8F6E\u56DE\u590D\u6587\u672C\u4E0E\u8C03\u7528\u8FC7\u7684\u5DE5\u5177\u540D\u3002\u5305\u542B\u89C4\u5219\uFF1A\u547D\u4E2D\u4EFB\u4E00\u624D\u901A\u77E5\uFF1B\u6392\u9664\u89C4\u5219\uFF1A\u547D\u4E2D\u5373\u4E0D\u901A\u77E5\u3002',\n 'settings.rules.empty': '\u6682\u65E0\u89C4\u5219\uFF0C\u6240\u6709\u5DF2\u542F\u7528\u7684\u5B8C\u6210\u72B6\u6001\u90FD\u4F1A\u901A\u77E5\u3002',\n 'settings.rules.add': '\u6DFB\u52A0\u89C4\u5219',\n 'settings.rules.save': '\u4FDD\u5B58\u89C4\u5219',\n 'settings.rules.mode.include': '\u5305\u542B',\n 'settings.rules.mode.exclude': '\u6392\u9664',\n 'settings.rules.patternPlaceholder': '\u5173\u952E\u8BCD\u6216\u6B63\u5219\u8868\u8FBE\u5F0F',\n 'settings.rules.regex': '\u6B63\u5219',\n 'settings.rules.case': '\u533A\u5206\u5927\u5C0F\u5199',\n 'settings.rules.remove': '\u5220\u9664\u89C4\u5219',\n 'settings.rules.invalid': '\u89C4\u5219\u6A21\u5F0F\u4E0D\u80FD\u4E3A\u7A7A',\n 'settings.rules.invalidRegex': '\u65E0\u6548\u7684\u6B63\u5219\u8868\u8FBE\u5F0F',\n 'settings.rules.unsaved': '\u89C4\u5219\u6709\u672A\u4FDD\u5B58\u7684\u4FEE\u6539',\n 'settings.rules.saveHint': '\u5148\u586B\u5199\u89C4\u5219\u6A21\u5F0F\uFF0C\u518D\u70B9\u4FDD\u5B58',\n 'settings.advanced.title': '\u9AD8\u7EA7',\n 'settings.advanced.requireInteraction': '\u9700\u8981\u624B\u52A8\u5173\u95ED',\n 'settings.advanced.requireInteractionDesc': '\u901A\u77E5\u4FDD\u6301\u663E\u793A\uFF0C\u76F4\u5230\u4F60\u624B\u52A8\u5173\u95ED\uFF08\u9002\u5408\u91CD\u8981\u4EFB\u52A1\uFF09\u3002',\n 'settings.advanced.backgroundOnly': '\u4EC5\u5728\u4EFB\u52A1\u4E0D\u5728\u773C\u524D\u65F6\u901A\u77E5',\n 'settings.advanced.backgroundOnlyDesc': '\u5F53\u524D\u4F1A\u8BDD\u6B63\u5728\u773C\u524D\u65F6\u4E0D\u63D0\u9192\uFF1B\u9875\u9762\u5728\u540E\u53F0\uFF0C\u6216\u4F60\u6B63\u5728\u67E5\u770B\u5176\u4ED6\u4F1A\u8BDD\u3001\u5176\u4ED6\u5DE5\u4F5C\u533A\u65F6\u4ECD\u4F1A\u63D0\u9192\u3002',\n 'notify.titleCompleted': 'DSH \u5DF2\u5B8C\u6210\u4EFB\u52A1',\n 'notify.titleError': 'DSH \u51FA\u9519\u4E86',\n 'notify.titleAborted': 'DSH \u5DF2\u4E2D\u6B62',\n 'notify.titleBlocked': 'DSH \u9700\u8981\u5904\u7406',\n 'notify.titleMaxTokens': 'DSH \u8FBE\u5230 Token \u4E0A\u9650',\n 'notify.emptyBody': '\u4EFB\u52A1\u5DF2\u5B8C\u6210',\n 'notify.testTitle': 'DSH \u901A\u77E5\u6D4B\u8BD5',\n 'notify.testBody': '\u5982\u679C\u4F60\u770B\u5230\u8FD9\u6761\u901A\u77E5\uFF0C\u8BF4\u660E\u901A\u77E5\u5DF2\u914D\u7F6E\u6210\u529F\u3002',\n} satisfies Record\n\n/** The `notification` namespace key union. */\nexport type NotificationKey = keyof typeof zh\n\n/** English dictionary, checked complete against the zh key set. */\nexport const en = {\n 'nav': 'Notifications',\n 'settings.title': 'Task completion notifications',\n 'settings.subtitle': 'Get a browser notification when DSH finishes an operation, with keyword rules to control exactly which messages notify.',\n 'settings.enabled': 'Enable notifications',\n 'settings.enabledDesc': 'Turning this off stops every notification; rules and preferences are kept.',\n 'settings.permission.title': 'Browser permission',\n 'settings.permission.desc': 'Notifications need browser permission. Grant it below, then send a test notification to confirm it works.',\n 'settings.permission.granted': 'Granted',\n 'settings.permission.denied': 'Denied (re-enable in the browser\\'s site settings)',\n 'settings.permission.default': 'Not granted',\n 'settings.permission.request': 'Request permission',\n 'settings.permission.test': 'Send test notification',\n 'settings.when.title': 'When to notify',\n 'settings.when.subtitle': 'Choose which end states trigger a notification.',\n 'settings.when.completed': 'Completed',\n 'settings.when.error': 'Failed',\n 'settings.when.aborted': 'Aborted',\n 'settings.when.blocked': 'Blocked',\n 'settings.when.maxTokens': 'Hit token limit',\n 'settings.rules.title': 'Keyword rules',\n 'settings.rules.subtitle': 'Rules match the turn\\'s reply text and called tool names. Include rules: notify only if one matches. Exclude rules: suppress on match.',\n 'settings.rules.empty': 'No rules yet \u2014 every enabled end state notifies.',\n 'settings.rules.add': 'Add rule',\n 'settings.rules.save': 'Save rules',\n 'settings.rules.mode.include': 'Include',\n 'settings.rules.mode.exclude': 'Exclude',\n 'settings.rules.patternPlaceholder': 'Keyword or regular expression',\n 'settings.rules.regex': 'Regex',\n 'settings.rules.case': 'Case sensitive',\n 'settings.rules.remove': 'Remove rule',\n 'settings.rules.invalid': 'Rule pattern must not be empty',\n 'settings.rules.invalidRegex': 'Invalid regular expression',\n 'settings.rules.unsaved': 'Rules have unsaved changes',\n 'settings.rules.saveHint': 'Fill in the rule pattern first, then save',\n 'settings.advanced.title': 'Advanced',\n 'settings.advanced.requireInteraction': 'Require manual dismiss',\n 'settings.advanced.requireInteractionDesc': 'The notification stays until you dismiss it (for important tasks).',\n 'settings.advanced.backgroundOnly': 'Only notify when the task is out of view',\n 'settings.advanced.backgroundOnlyDesc': 'Suppress notifications only for the session currently in view; still notify in the background or while viewing another session or workspace.',\n 'notify.titleCompleted': 'DSH finished',\n 'notify.titleError': 'DSH failed',\n 'notify.titleAborted': 'DSH aborted',\n 'notify.titleBlocked': 'DSH needs attention',\n 'notify.titleMaxTokens': 'DSH hit the token limit',\n 'notify.emptyBody': 'The task is done',\n 'notify.testTitle': 'DSH notification test',\n 'notify.testBody': 'If you can see this notification, notifications are configured correctly.',\n} satisfies Record\n\n/** Locale namespace id registered under ctx.locale. */\nexport const NS = 'notification'\n\n/**\n * Fill one dictionary template's `{name}`-style placeholders.\n * @param template - dictionary text.\n * @param params - placeholder values; absent params replace nothing.\n * @returns the filled text.\n */\nexport function fmt(template: string, params?: Record): string {\n if (params === undefined) return template\n return template.replace(/\\{(\\w+)\\}/g, (whole, key: string) => params[key] ?? whole)\n}\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** The notification settings copy and titles. */\n [NS]: NotificationKey\n }\n}\n", "/**\n * The settings-section stylesheet, hand-written as a template string and\n * injected once by the plugin body: the web server serves exactly one file per\n * client plugin, so no separate CSS artifact may exist. Tokens come only from\n * the shared `--dsw-alias-*` design platform (no literal colors); class names\n * carry the `dsh_notification` prefix to stay unique in the assembled shell.\n */\n\n/** Stable `