diff --git a/docs/webui.md b/docs/webui.md index b1ed0420..9231f74e 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -1348,6 +1348,46 @@ clients: | `file_line_wrap` | `"true"` | **Yes.** On wraps over-wide lines; off scrolls horizontally. Covers both code-file previews (ticket 48) and markdown codeblocks — chat messages, activity groups and markdown file previews (ticket 52); the language label never wraps. Applies to previews opened / messages mounted after the switch (an already-open one does not reflow); a wrapped file-preview line's gutter number aligns with its first visual row — a known trade-off | | `webui-context-window-usage` | `"false"` | **Yes.** On draws the context-window readout in the composer's toolbar, immediately left of the model chip; off renders nothing there. The readout's own form is unchanged — the ring, the percentage, the breakdown and the plan rows all come from the session snapshot as before. Flipping the switch takes effect without a reload | | `webui-follow-up-behavior` | `"queue"` (or `"off"`, `"steer"`) | No. Decides what a send does while a turn runs; `"off"` is webui's own third position (the reference has two) | +| `webui-desktop-notifications` | `"false"` | **Yes.** On raises a browser notification when a turn finishes, when a tool needs a decision, or when a turn fails — and only while this page is in the background. See **Desktop notifications (SB-9)** below | + +**Desktop notifications (SB-9)** + +The 桌面通知 row on the General page is the one switch in the 应用 block that +is neither an OS integration nor a placeholder. The decision about *whether* +to notify is a pure function over four facts in +`webapp/lib/desktop-notify.ts#shouldShowDesktopNotification` — the stored +switch, the browser's live `Notification.permission`, whether the browser +supports the API at all, and `document.visibilityState`. The browser calls +live at the edges of that module and nowhere else. + +| Question | Answer | Why | +| --- | --- | --- | +| Which events notify? | turn settled, a `needs_authorization` request arrived, an `error`-level anomaly landed on the alerts stream | Each already arrives on a stream this client subscribes to, so no new server channel was added. Subagent completion and plan review are deliberately not separate triggers: both arrive as `needs_authorization`, and a fourth trigger would be a fourth thing to get wrong | +| When is a notification suppressed? | whenever `document.visibilityState === "visible"`, for **all three** kinds | A notification that duplicates something already on screen is noise. Each of the three has an on-screen face: the finished conversation is in the tab in front of the user, the decision prompt is a blocking modal, and the failure is already in the alerts badge and the action-error banner. The reference has no such rule because it has no page to be looking at | +| What happens when a turn fails? | one notification, not two | The error and the settle arrive on **two different SSE connections** (alerts vs. events) and nothing orders two HTTP responses, so either order is possible. The error is remembered against its session and a completion on that session inside a 5 s window is swallowed; outside the window the stale entry is ignored, so it cannot suppress an unrelated turn later. The entry is consumed on use | +| What about a tab opened over a busy server? | the alerts opening snapshot is history, not news | `GET /api/alerts` opens with a ring buffer of up to 200 pre-load alerts. `lib/alerts.ts#historySealed` reports whether that frame has arrived, and until it has nothing in the buffer is announced. Told by the frame's own kind, never guessed from whether the list happens to be empty — an empty history and an unwatched first failure are identical in a list | +| What does the click do? | focuses the browser window, then switches to the named session if it is not the one on screen | The reference's click target is a tab in its own tab strip. Here one browser tab is one conversation, so the browser window *is* the tab; `app/page.tsx` registers that behaviour with `registerDesktopNotifyFocusHandler` rather than letting the notifier reach into `lib/api` | +| What does the switch do? | turning it **on** is the permission request; it latches only on `granted` | A switch that reads ON while the browser has refused is a dishonest state, and it would notify nobody. Turning it off never asks anything | +| What is shown after a refusal? | the row's own hint gains a second sentence naming the refusal and the way out, in the status colour | The permission is read live on every mount of the settings page and is never persisted — a stored `granted` would outlive the user revoking it in the browser's site settings. Reopening the settings page is therefore enough to see the refusal | +| Is the permission ever stored? | no | `Notification.permission` is owned by the browser and revocable outside the page | + +`Notification.requestPermission()` is only ever called from the switch's +`onChange` — never at load. A permission prompt raised before the user has +expressed any intent is the pattern browsers penalise, and the switch *is* +the request. The default is therefore `"false"`: a profile that never touched +the switch asks for nothing and shows nothing. + +**How to tell it works** + +1. Settings → General → 桌面通知, turn it on, accept the browser prompt. +2. Switch to another browser tab (or window) while a turn is running. +3. On the settle you get one notification; clicking it brings the window back + and lands on that session. +4. Raise a turn failure (an unknown `/cmd` produces an `[chat.send]` + anomaly): you get the failure notification, and **not** a second + "finished" one. +5. Revoke the permission in the browser's site settings and reopen the + settings page: the switch reads off and the refusal sentence is there. **The context-window readout** @@ -2765,6 +2805,7 @@ Invariants worth keeping when touching either branch: | `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | tickets 48 + 52 | bare `"true"\|"false"` string, reference-shared namespace; default `"true"`; read per mount by `components/code-view.tsx` (code-file previews) and `components/markdown-html.tsx` (markdown codeblocks: chat, activity groups, file previews) | | `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 | bare `"true"\|"false"` string, reference-shared namespace; default `"false"`; read at mount and followed live by `components/context-meter.tsx` through `subscribeContextWindowUsage` | | `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 / SB-4 | bare `"off"\|"queue"\|"steer"` string (anything else reads as `"queue"`), reference-shared namespace; read by `components/composer.tsx` and republished on every write | +| `webui-desktop-notifications` | `localStorage` | `webapp/lib/settings-local.ts` | SB-9 | bare `"true"\|"false"` string; default `"false"`; written by the General page's 桌面通知 row, published on every write for `subscribeDesktopNotifications`. Stores the user's **intent only** — the browser's `Notification.permission` is read live from `webapp/lib/desktop-notify.ts` and is never persisted | | `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | ticket 55c (settings Shortcuts page) | `{"global-search":"Ctrl+Shift+P", …}` — rebindings of the **live** shortcut rows only, written when the user records a new combination and removed entirely when the last one is cleared. Re-validated against the registry on read: a stored id that is no longer dispatched, or a chord that no longer parses, is dropped rather than honoured, so a hand-edited entry cannot widen what the page dispatches. Read at every keydown by `app/page.tsx` (through `effectiveBindings`) and once per mount by the settings page | | `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | ticket 55c (project context menu) | `{version:1, titles:{:}, pinned:[]}`. **Deliberately not cid-namespaced**: a rename or a pin describes the project, not a browser session, so every tab of this browser shares it. Best-effort write, silent failure; a project's entries are cleared when its remove completed with every session deleted | diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 7f46bce5..a99920f7 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -1117,6 +1117,42 @@ slice 22 增强: | `file_line_wrap` | `true` | **是**。开启时超宽行自动折行;关闭时横向滚动。覆盖两类表面:代码文件预览(工单 48)与 markdown 代码块——聊天消息、活动组、markdown 文件预览(工单 52);语言标签不随代码行折行。对之后打开的预览/之后挂载的消息生效(已打开的不重排);文件预览折行后行号与第二视觉行不对齐,是已知取舍 | | `webui-context-window-usage` | `false` | **是**。开启时在输入区工具栏(紧挨模型选择器左侧)绘制上下文用量计量;关闭时该处不渲染任何内容。计量本身的形态不变——圆环、百分比、分类明细、套餐各行仍照旧来自会话快照。拨动开关无需刷新页面即生效 | | `webui-follow-up-behavior` | `queue`(可选 `off`、`steer`) | 否。决定回合运行中发送的去向;`off` 是 webui 自有的第三态(参照只有两态) | +| `webui-desktop-notifications` | `false` | **是**。开启时在回合结束、工具等待授权确认、回合失败三种情形下弹出浏览器通知,且仅在本页处于后台时弹出。详见下文**桌面通知(SB-9)** | + +**桌面通知(SB-9)** + +通用页「应用」分区里的「桌面通知」是该分区唯一一个既非操作系统集成、 +也非占位的开关。是否弹出的判断是 +`webapp/lib/desktop-notify.ts#shouldShowDesktopNotification` 里的纯函数, +输入只有四个事实:存储的开关、浏览器实时的 `Notification.permission`、 +浏览器是否支持该 API、以及 `document.visibilityState`。所有浏览器调用 +只出现在该模块的边界上,别处一律不碰。 + +| 问题 | 结论 | 理由 | +| --- | --- | --- | +| 哪些事件会通知 | 回合结束、`needs_authorization` 授权请求到达、告警流上落下一条 `error` 级异常 | 三者本来就各自落在本客户端已订阅的流上,因此没有新增任何服务端通道。子代理完成与计划评审**有意不单列**:它们都以 `needs_authorization` 的形式到达,再加第四个触发点只是多一个可能出错的地方 | +| 什么情况下不弹 | `document.visibilityState === "visible"` 时**三种一律不弹** | 重复屏幕上已有内容的通知就是噪音。三种情形在屏幕上都有对应的落点:结束的会话就在用户正看着的标签页里,等待确认是阻塞式弹窗,失败已经进了告警徽标与操作错误横幅。桌面参照没有这条规则,因为它没有「用户正在看的页面」这回事 | +| 回合失败会弹几条 | 一条,不是两条 | 错误与回合结束走的是**两条不同的 SSE 连接**(告警流与事件流),两个 HTTP 响应之间没有任何顺序保证,所以两种到达顺序都可能。错误按会话记下,落在 5 秒窗口内的结束通知被吞掉;超出窗口的陈旧记录直接忽略,不会压掉一小时后的另一个回合。记录用后即消费 | +| 在繁忙服务端上打开新标签页会怎样 | 告警流的首帧快照是历史,不是新消息 | `GET /api/alerts` 打开时先下发一个最多 200 条的环形缓冲快照。`lib/alerts.ts#historySealed` 报告该帧是否已到,未到之前缓冲里的内容一律不播报。只依据帧自身的种类判断,绝不靠列表是否为空来猜——「空历史」与「第一条未被观看的失败」在一个列表里长得一模一样 | +| 点击通知做什么 | 先聚焦浏览器窗口;若通知指向的会话不是当前会话,再切过去 | 桌面参照的点击目标是它自己标签条里的某个标签页。本客户端一个浏览器标签页就是一个会话,所以浏览器窗口**就是**那个标签页;该行为由 `app/page.tsx` 通过 `registerDesktopNotifyFocusHandler` 注册,通知模块自己不伸手去调 `lib/api` | +| 开关做什么 | **打开开关即发起授权请求**,且只有在浏览器返回 `granted` 时才真正落到 ON | 浏览器已经拒绝而开关显示 ON 是不诚实状态,而且它一条也弹不出来。关闭开关不会发起任何请求 | +| 被拒绝后显示什么 | 该行原有的说明文字下多出第二句,用状态色写明「已拒绝」与解法 | 权限在设置页每次挂载时实时读取,且从不落盘——存下来的 `granted` 会比用户在浏览器站点设置里的撤销活得更久。因此重新打开设置页就能看到拒绝态 | +| 权限会被持久化吗 | 不会 | `Notification.permission` 归浏览器所有,且可在页面之外撤销 | + +`Notification.requestPermission()` 只在开关的 `onChange` 里被调用, +绝不会在加载时自动弹出。在用户尚未表达意图前索要权限正是浏览器会惩罚的 +模式,而开关本身就是那个请求。因此默认值是 `false`:从未碰过这个开关的 +配置既不询问也不弹任何东西。 + +**如何验证它真的工作** + +1. 设置 → 通用 → 桌面通知,打开开关,在浏览器弹窗中同意。 +2. 回合运行中把页面切到另一个浏览器标签页(或窗口)。 +3. 回合结束时收到一条通知;点击后窗口回到前台,并落在那个会话上。 +4. 制造一次回合失败(一条无法识别的 `/cmd` 会产生 `[chat.send]` 告警): + 收到失败通知,且**不会**再多出一条「已完成」。 +5. 在浏览器站点设置里撤销通知权限后重新打开设置页:开关显示为关, + 且拒绝文案就在该行说明里。 **上下文用量计量** @@ -2042,6 +2078,7 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别 | `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 + 52 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"true"`;每次挂载读取方为 `components/code-view.tsx`(代码文件预览)与 `components/markdown-html.tsx`(markdown 代码块:聊天、活动组、文件预览) | | `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"false"`;`components/context-meter.tsx` 挂载时读取一次,并通过 `subscribeContextWindowUsage` 实时跟随 | | `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 / SB-4 | 纯 `"off"\|"queue"\|"steer"` 字符串(其他值读取为 `"queue"`),参照共享命名;由 `components/composer.tsx` 读取,每次写入都会重新发布 | +| `webui-desktop-notifications` | `localStorage` | `webapp/lib/settings-local.ts` | SB-9 | 纯 `"true"\|"false"` 字符串;默认 `"false"`;由通用页「桌面通知」行写入,每次写入都发布给 `subscribeDesktopNotifications`。只存用户的**意图**——浏览器的 `Notification.permission` 由 `webapp/lib/desktop-notify.ts` 实时读取,从不落盘 | | `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | 工单 55c(设置快捷键页) | `{"global-search":"Ctrl+Shift+P", …}`——**已生效**行的改键记录,用户录入新组合时写入,清掉最后一条时整个键删除。读取时按注册表重新校验:已不再分发的行 id、或已无法解析的组合一律丢弃,手工改过的存储项无法借此扩大页面的分发面。`app/page.tsx` 每次键盘事件经 `effectiveBindings` 读取,设置页每次挂载读取一次 | | `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | 工单 55c(项目右键菜单) | `{version:1, titles:{<项目key>:<自定义名>}, pinned:[<项目key>]}`。**不按 cid 命名空间**(有意):重命名与置顶描述的是项目本身而非某个浏览器会话,同一浏览器的所有标签页共享。写入尽力而为,失败静默;项目被完整移除(全部会话删除成功)时同步清除其条目 | diff --git a/packages/webui/server/engine/model-source.js b/packages/webui/server/engine/model-source.js index 3cd0beb1..a034142d 100644 --- a/packages/webui/server/engine/model-source.js +++ b/packages/webui/server/engine/model-source.js @@ -310,14 +310,9 @@ export function publicApiKeyStatus(status) { const record = status && typeof status === "object" ? status : {}; const cached = record.cachedStatus && typeof record.cachedStatus === "object" ? record.cachedStatus : {}; const lastTested = typeof cached.lastTestedAt === "number" ? cached.lastTestedAt : null; - // Destructured, not read as `record.hasApiKey` in the projection: the bundler - // renames `record` to a generated identifier, and a `hasKey: .` - // pair reads to the credential scanner as `hasKey = <16+ char secret>` inside the - // bundled distribution, failing the release audit on a boolean comparison. - const { hasApiKey } = record; return { available: true, - hasKey: hasApiKey === true, + hasKey: record.hasApiKey === true, masked: typeof record.maskedApiKey === "string" ? record.maskedApiKey : null, testState: typeof cached.state === "string" ? cached.state : null, lastTestedAtMs: lastTested, diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index 326c2037..c0475dfe 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -8,6 +8,9 @@ import { Composer } from "@/components/composer"; import { TranscriptSkeleton } from "@/components/loading-states"; import { Modals } from "@/components/modals"; import { ActionErrorBanner } from "@/components/action-error-banner"; +// SB-9:桌面通知的唯一挂载点(见该组件文件头的分工说明)。 +import { DesktopNotifySync } from "@/components/desktop-notify-sync"; +import { registerDesktopNotifyFocusHandler } from "@/lib/desktop-notify"; // Settings modal port (webui-parity 58): the reference SettingsModal // structure; the shim keeps this import shape unchanged. import { SettingsModal } from "@/components/settings-modal-port"; @@ -17,7 +20,7 @@ import { WorkspaceColumns } from "@/components/workspace-columns"; import { PreviewColumn, PreviewColumnMounted } from "@/components/workspace-tabs"; import { TreeColumn } from "@/components/workspace-tree-column"; import { runAction } from "@/lib/action-errors"; -import { SessionProvider, useSessionContext } from "@/lib/store"; +import { SessionProvider, getActiveSessionId, useSessionContext } from "@/lib/store"; import { decodeTranscript } from "@/lib/transcript"; import { useLocale } from "@/lib/use-locale"; import { @@ -212,6 +215,30 @@ function App() { return nextTabs; }); }, + [], ); + + /** + * SB-9 — what a desktop notification's click does. + * + * The page root is the only place that knows how a session switch is + * performed, so it registers that behaviour with `lib/desktop-notify.ts` + * rather than letting the notifier reach into `lib/api` itself. The contract + * is one sentence: bring the named session into view, and do nothing when it + * is already the one on screen (a notification for the current session is + * still worth clicking — it raises the browser window — but it must not + * re-issue a switch that would reload the conversation). + * + * The desktop reference's click target is a tab in its own tab strip. webui + * has no equivalent: one browser tab is one conversation, so "the tab" is the + * browser window, which `lib/desktop-notify.ts` focuses before calling this. + */ + useEffect( + () => + registerDesktopNotifyFocusHandler((sessionId) => { + if (!sessionId) return; + if (getActiveSessionId() === sessionId) return; + void api.switchSession(sessionId); + }), [], ); @@ -797,6 +824,7 @@ function App() { ) : null} + ); diff --git a/packages/webui/webapp/components/desktop-notify-sync.tsx b/packages/webui/webapp/components/desktop-notify-sync.tsx new file mode 100644 index 00000000..376f4234 --- /dev/null +++ b/packages/webui/webapp/components/desktop-notify-sync.tsx @@ -0,0 +1,86 @@ +"use client"; + +/** + * Desktop notifications — the one mount point (SB-9). + * + * Mounted once at the root of the page, exactly like `AppearanceSync`, and for + * the same reason: the three triggers are spread across three different + * subscriptions (the state stream, the alerts stream, and the `Notification` + * API itself), and the only place that can see all three at once is the root. + * + * This component owns no policy. Every decision — which events notify, how a + * failure and its completion collapse into one notification, whether the user + * is already looking at the tab — lives in `lib/desktop-notify.ts` as pure + * functions. Here it only: + * + * 1. reads the three subscriptions, + * 2. folds them into one `advanceDesktopNotifyCursor` call, and + * 3. renders nothing. + * + * Folding all three into ONE call per effect pass is deliberate. Folding them + * separately would let two decisions be raised in the same pass and one of them + * would be lost, which is how a failed turn ends up saying "done". + */ + +import { useEffect, useRef } from "react"; + +import { useSessionContext } from "@/lib/store"; +import { useAlerts } from "@/lib/alerts"; +import { + advanceDesktopNotifyCursor, + showDesktopNotification, + INITIAL_NOTIFY_CURSOR, + type DesktopNotifyCursor, +} from "@/lib/desktop-notify"; +import { useLocale } from "@/lib/use-locale"; + +export function DesktopNotifySync(): null { + const { state, authorize } = useSessionContext(); + const { alerts, historySealed } = useAlerts(); + const { t } = useLocale(); + // The cursor is a fold, not render state: it must survive re-renders without + // being a reason to re-render. `useRef` rather than `useState` for exactly + // that reason — writing it never re-runs the effect that consumes it. + const cursorRef = useRef(INITIAL_NOTIFY_CURSOR); + + useEffect(() => { + const advanced = advanceDesktopNotifyCursor(cursorRef.current, { + runningActive: state ? state.running.active : null, + sessionId: state?.sessionId ?? null, + authorizeRequestId: authorize?.requestId ?? null, + alerts, + alertsHistorySealed: historySealed, + nowMs: Date.now(), + }); + cursorRef.current = advanced.cursor; + const decision = advanced.decision; + if (decision.kind === "none") return; + + if (decision.kind === "turn-complete") { + showDesktopNotification({ + kind: "turn-complete", + title: t("notify.turnComplete.title"), + body: t("notify.turnComplete.body"), + sessionId: decision.sessionId, + }); + return; + } + if (decision.kind === "needs-confirmation") { + showDesktopNotification({ + kind: "needs-confirmation", + title: t("notify.needsConfirmation.title"), + body: t("notify.needsConfirmation.body"), + sessionId: decision.sessionId, + }); + return; + } + showDesktopNotification({ + kind: "turn-error", + title: t("notify.turnError.title"), + body: decision.message || t("notify.turnError.body"), + sessionId: decision.sessionId, + }); + }, [state, authorize, alerts, historySealed, t]); + + return null; +} diff --git a/packages/webui/webapp/components/settings-modal-port.tsx b/packages/webui/webapp/components/settings-modal-port.tsx index 185ede78..ae28ad7f 100644 --- a/packages/webui/webapp/components/settings-modal-port.tsx +++ b/packages/webui/webapp/components/settings-modal-port.tsx @@ -41,14 +41,23 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, t import * as api from "@/lib/api"; import { commitContextWindowUsage, + commitDesktopNotifications, commitFileLineWrap, commitFileOpenInNewTab, commitFollowUpBehavior, readContextWindowUsage, + readDesktopNotifications, readFileLineWrap, readFileOpenInNewTab, readFollowUpBehavior, } from "@/lib/settings-local"; +// SB-9:权限读取与授权请求;策略在 lib/desktop-notify.ts,此处只用这两个 +// 浏览器边界调用。 +import { + desktopNotifyPermission, + requestDesktopNotifyPermission, + type DesktopNotifyPermission, +} from "@/lib/desktop-notify"; import { applyAppearance, currentAppearance } from "@/lib/theme"; import type { Locale, MessageKey } from "@/lib/i18n"; import { ProviderManagementPanel } from "./provider-management"; @@ -557,6 +566,93 @@ export function SettingsModalPort({ ); } +// --- 桌面通知(SB-9)-------------------------------------------------------- + +/** + * The 桌面通知 row, wired for real. + * + * Two independent facts decide what this row shows, and conflating them is the + * defect this component exists to avoid: + * + * the switch — the user's stored intent, `webui-desktop-notifications`. + * the permission — the browser's live `Notification.permission`, which the + * user can revoke from the browser's site settings at any + * time without this page hearing about it. + * + * So the permission is read on mount, never stored, and never inferred from + * the switch: a switch that is ON while the permission is `denied` is a + * dishonest state, and turning it ON is the moment the permission is asked + * for. A refusal leaves the switch OFF and states the refusal, rather than + * leaving an enabled switch that silently notifies nobody. + * + * The note renders on its own terms — a user who revoked the permission in the + * browser and then reopened the settings page must be told, not left to + * discover it by noticing that nothing arrives. + */ +function DesktopNotificationsRow({ + t, +}: { + readonly t: (key: MessageKey) => string; +}): ReactElement { + const [enabled, setEnabled] = useState(() => readDesktopNotifications()); + const [permission, setPermission] = useState(() => + desktopNotifyPermission(), + ); + + const apply = useCallback((value: boolean) => { + commitDesktopNotifications(setEnabled, value); + }, []); + + const onToggle = useCallback( + (next: boolean) => { + if (!next) { + apply(false); + return; + } + // Turning ON is the request. The switch only latches once the browser + // has actually granted, so it never claims a capability it lacks. + void requestDesktopNotifyPermission().then((granted) => { + setPermission(granted); + apply(granted === "granted"); + }); + }, + [apply], + ); + + const note = + permission === "unsupported" + ? t("settings.app.notificationsUnsupported") + : permission === "denied" + ? t("settings.app.notificationsDenied") + : null; + + return ( + + {t("settings.app.notificationsHint")} + + {note} + + + ) + } + testId="desktop-notifications-row" + > + + + ); +} + // --- 通用页(参照 GenericPage 全区块)-------------------------------------- function GenericPage({ @@ -588,7 +684,6 @@ function GenericPage({ ); - return (
@@ -616,7 +711,7 @@ function GenericPage({ {off(t("settings.app.autoStart"), t("settings.app.autoStartHint"))} - {off(t("settings.app.notifications"), t("settings.app.notificationsHint"))} + {off(t("settings.app.earlyAccess"), t("settings.app.earlyAccessHint"), "early-access-update-switch")} diff --git a/packages/webui/webapp/lib/alerts.ts b/packages/webui/webapp/lib/alerts.ts index c1eee3ce..da814ffa 100644 --- a/packages/webui/webapp/lib/alerts.ts +++ b/packages/webui/webapp/lib/alerts.ts @@ -105,13 +105,28 @@ export function applyAlertFrame(current: AlertItem[], frame: AlertFrame): AlertI interface AlertSnapshot { alerts: AlertItem[]; connected: boolean; + /** + * SB-9 — `true` once the stream's opening `snapshot` frame has been + * applied, i.e. the ring buffer holds no more pre-load history. + * + * The distinction the frame model already makes (`snapshot` is what + * happened before this page connected; `append` / `update` are what happens + * while it is watching) and the alert list alone cannot express: an empty + * buffer is not evidence that history has been delivered, and a first + * `append` into an empty buffer is indistinguishable from a `snapshot` that + * happened to be empty. A consumer that must tell history from news — the + * desktop notifier does — needs this flag rather than guessing. + */ + historySealed: boolean; } -let snapshot: AlertSnapshot = { alerts: [], connected: false }; +const EMPTY_SNAPSHOT: AlertSnapshot = { alerts: [], connected: false, historySealed: false }; + +let snapshot: AlertSnapshot = EMPTY_SNAPSHOT; const listeners = new Set<() => void>(); -function setAlerts(alerts: AlertItem[]): void { - snapshot = { ...snapshot, alerts }; +function setAlerts(alerts: AlertItem[], historySealed = snapshot.historySealed): void { + snapshot = { ...snapshot, alerts, historySealed }; for (const listener of listeners) listener(); } @@ -129,7 +144,6 @@ function subscribe(listener: () => void): () => void { const getSnapshot = (): AlertSnapshot => snapshot; /** Static-export prerender: no live stream, so the constant empty snapshot. */ const getServerSnapshot = (): AlertSnapshot => EMPTY_SNAPSHOT; -const EMPTY_SNAPSHOT: AlertSnapshot = { alerts: [], connected: false }; let source: EventSource | null = null; @@ -156,7 +170,11 @@ export function connectAlerts(): () => void { // diagnostic by nature, and a single bad frame must not blank the badge // or throw into render. if (frame.kind === "malformed") return; - setAlerts(applyAlertFrame(snapshot.alerts, frame)); + // A `snapshot` frame is the pre-load history; every later frame is news. + setAlerts( + applyAlertFrame(snapshot.alerts, frame), + snapshot.historySealed || frame.kind === "snapshot", + ); }; eventSource.onmessage = (event: MessageEvent) => handle("", event.data); diff --git a/packages/webui/webapp/lib/desktop-notify.ts b/packages/webui/webapp/lib/desktop-notify.ts new file mode 100644 index 00000000..a072b19b --- /dev/null +++ b/packages/webui/webapp/lib/desktop-notify.ts @@ -0,0 +1,380 @@ +"use client"; + +/** + * Desktop notifications (SB-9) — the decision core plus a thin browser adapter. + * + * The split is deliberate and is the whole design: every rule about *whether* + * to notify is a pure function over four facts, so the policy is testable + * without a DOM, a permission prompt, or a clock. The browser calls + * (`Notification`, `document.visibilityState`, `window.focus`) live at the + * edges and nowhere else. + * + * Three triggers, and why exactly these three: + * + * `turn-complete` the settle of `running.active` (true → false). + * `needs-confirmation` a `needs_authorization` request awaiting an answer. + * `turn-error` an `error`-level anomaly on the alerts stream. + * + * All three already arrive on streams this client subscribes to, so nothing + * here invents a new server channel. + * + * The one rule that applies to all three is the noise floor: a notification + * that duplicates something already on screen is noise, so `visible` blocks + * every kind. For `turn-complete` the finished conversation is in the tab the + * user is looking at; for `needs-confirmation` the blocking modal is in front + * of them; for `turn-error` the alerts badge and the action-error banner are + * both already raised. The desktop reference has no equivalent rule because it + * has no page to be looking at. + */ + +import { readDesktopNotifications } from "./settings-local"; + +/** What happened. Each maps to one of the three streams the client reads. */ +export type DesktopNotifyKind = "turn-complete" | "needs-confirmation" | "turn-error"; + +/** + * The browser's notification capability as this page sees it. + * + * `"unsupported"` is a first-class member, not an error: a browser without the + * `Notification` constructor (or a non-secure origin, where the constructor is + * absent) must be reported as "cannot do this" rather than crashing a module + * that the settings page imports unconditionally. + */ +export type DesktopNotifyPermission = NotificationPermission | "unsupported"; + +/** The four facts the decision is made from. */ +export interface DesktopNotifyFacts { + /** The settings-page switch, read from `localStorage`. */ + readonly enabled: boolean; + /** The browser's live permission — never persisted, never assumed. */ + readonly permission: DesktopNotifyPermission; + /** `document.visibilityState === "visible"`. */ + readonly visible: boolean; +} + +/** + * Should this event raise a notification? + * + * Order matters only for readability; the gates are independent. `unsupported` + * and a non-`granted` permission both mean the constructor will not show + * anything, and asking for one anyway would surface a silent no-op as a + * working feature. + */ +export function shouldShowDesktopNotification( + kind: DesktopNotifyKind, + facts: DesktopNotifyFacts, +): boolean { + if (facts.permission === "unsupported") return false; + if (facts.permission !== "granted") return false; + if (!facts.enabled) return false; + // The noise floor, shared by all three kinds — see the module docblock. + if (facts.visible) return false; + // `kind` is carried but not read, and that is the point of carrying it. It + // is what lets a test assert the shared rule PER KIND rather than once for + // an unnamed event, and it is the seam a genuine per-kind rule (a rate limit + // on repeated failures, say) belongs in. Reading it into the decision today + // would be a fabricated branch. + void kind; + return true; +} + +/** One notification, ready to hand to the browser. */ +export interface DesktopNotificationInput { + readonly kind: DesktopNotifyKind; + readonly title: string; + readonly body: string; + /** + * The session the notification is about. Carried on the click path so the + * user lands on the conversation the notification refers to, not on whatever + * the page happened to be showing. + */ + readonly sessionId: string | null; +} + +/** + * A failed turn must raise exactly ONE notification, not two. + * + * The error reaches this client over a different connection than the state + * stream (the alerts SSE vs. the events SSE), and the server pushes the alert + * just before the broadcast that settles the turn — but nothing orders two + * HTTP responses. So the two can be seen in either order, and a failed turn + * that raised both would say "done" and "failed" about the same turn. + * + * The rule is a bounded coalescing window rather than an ordering assumption: + * an error notification is remembered against its session, and a turn + * completion on that session inside the window is swallowed. The window is + * short on purpose — it exists to cover the delivery skew between two local + * streams, not to suppress a legitimate later notification. Entries outside + * the window are stale and are ignored, so an error with no completion after + * it cannot suppress an unrelated turn an hour later. + */ +export const TURN_ERROR_COALESCE_MS = 5_000; + +/** The signalling cursor the sync component threads through each observation. */ +export interface DesktopNotifyCursor { + /** The last observed `running.active`. `null` before the first state. */ + readonly runningActive: boolean | null; + /** The last announced `authorize.requestId`; guards against a re-sent frame. */ + readonly announcedRequestId: string | null; + /** Alert ids already accounted for. */ + readonly seenAlertIds: ReadonlySet; + /** + * `false` until the first NON-EMPTY alerts snapshot is absorbed. The stream + * opens with a ring-buffer snapshot of everything that happened before this + * page loaded, and none of it is news the user is waiting on — notifying for + * it would fire up to 200 notifications for a tab that was just opened. + */ + readonly alertsSeeded: boolean; + /** sessionId → when its error notification was raised. */ + readonly lastErrorAt: ReadonlyMap; +} + +export const INITIAL_NOTIFY_CURSOR: DesktopNotifyCursor = { + runningActive: null, + announcedRequestId: null, + seenAlertIds: new Set(), + alertsSeeded: false, + lastErrorAt: new Map(), +}; + +/** What the policy says to raise, given the cursor and the fresh observations. */ +export type DesktopNotifyDecision = + | { readonly kind: "none" } + | { readonly kind: "turn-complete"; readonly sessionId: string | null } + | { + readonly kind: "needs-confirmation"; + readonly sessionId: string | null; + } + | { + readonly kind: "turn-error"; + readonly sessionId: string | null; + readonly message: string; + }; + +/** One observation batch: what changed since the previous cursor. */ +export interface DesktopNotifyObservation { + /** `running.active` from the newest state snapshot, or `null` for none. */ + readonly runningActive: boolean | null; + /** The session the newest snapshot belongs to, or `null`. */ + readonly sessionId: string | null; + /** The pending authorize request's id, or `null` when there is none. */ + readonly authorizeRequestId: string | null; + /** The alerts ring buffer as it stands now (newest first). */ + readonly alerts: readonly { + id: string; + level: string; + msg: string; + sessionId: string | null; + }[]; + /** + * Whether the alerts stream has delivered its opening `snapshot` frame + * (`lib/alerts.ts#historySealed`). Until it has, the buffer is pre-load + * history and none of it is news the user is waiting on. + */ + readonly alertsHistorySealed: boolean; + /** `Date.now()`-shaped; injected so the window is testable without a clock. */ + readonly nowMs: number; +} + +const NO_DECISION: DesktopNotifyDecision = { kind: "none" }; + +/** + * Advance the cursor over one observation batch and say what to raise. + * + * Errors win over completions inside the coalescing window, so a failed turn + * produces one notification and a successful one produces the completion. + * Returns the new cursor alongside, because the caller must carry the seen-set + * and the running latch forward — this is a fold, not a query. + */ +export function advanceDesktopNotifyCursor( + cursor: DesktopNotifyCursor, + observation: DesktopNotifyObservation, +): { cursor: DesktopNotifyCursor; decision: DesktopNotifyDecision } { + const seenAlertIds = new Set(cursor.seenAlertIds); + const lastErrorAt = new Map(cursor.lastErrorAt); + let decision: DesktopNotifyDecision = NO_DECISION; + + // --- errors, off the alerts stream ----------------------------------------- + const incoming = observation.alerts.filter((alert) => !seenAlertIds.has(alert.id)); + for (const alert of incoming) seenAlertIds.add(alert.id); + // The stream's opening `snapshot` frame is pre-load history: its alerts are + // marked seen so they can never be announced, and they are not announced now + // either. Told by the stream's own frame kind, not guessed from emptiness — + // an empty history and an unwatched first failure look identical in a list. + const isHistoryBatch = !cursor.alertsSeeded && observation.alertsHistorySealed; + const alertsSeeded = cursor.alertsSeeded || observation.alertsHistorySealed; + const freshErrors = isHistoryBatch + ? [] + : incoming.filter((alert) => alert.level === "error"); + + if (freshErrors.length > 0) { + // The ring buffer is newest-first, so the first match is the latest failure + // when several arrived in one batch. + const latest = freshErrors[0]; + if (!latest) { + // Defensive: `incoming` is empty whenever `freshErrors` is, so this cannot + // be reached; the narrowing keeps the rest of the function total. + } else { + const sessionId = latest.sessionId ?? observation.sessionId; + if (sessionId !== null) lastErrorAt.set(sessionId, observation.nowMs); + decision = { kind: "turn-error", sessionId, message: latest.msg }; + } + } + + // --- a blocking decision prompt, off the authorize request ----------------- + let announcedRequestId = cursor.announcedRequestId; + if (observation.authorizeRequestId !== null) { + // A re-sent frame for the same request must not notify twice. + if (observation.authorizeRequestId !== cursor.announcedRequestId) { + announcedRequestId = observation.authorizeRequestId; + decision = { kind: "needs-confirmation", sessionId: observation.sessionId }; + } + } else { + announcedRequestId = null; + } + + // --- completions, off the running latch ------------------------------------ + const settled = cursor.runningActive === true && observation.runningActive === false; + if (settled && decision.kind === "none") { + const sessionId = observation.sessionId; + const errorAt = sessionId === null ? undefined : lastErrorAt.get(sessionId); + const withinWindow = + errorAt !== undefined && observation.nowMs - errorAt <= TURN_ERROR_COALESCE_MS; + if (withinWindow) { + // Consumed: this completion is the failed turn's own and has already been + // accounted for. Leaving the entry would let it suppress a later, + // unrelated completion on the same session. + if (sessionId !== null) lastErrorAt.delete(sessionId); + } else { + decision = { kind: "turn-complete", sessionId }; + } + } + + return { + cursor: { + runningActive: observation.runningActive, + announcedRequestId, + seenAlertIds, + alertsSeeded, + lastErrorAt, + }, + decision, + }; +} + +/** + * Brings a session into view. Registered once by the page root, which is the + * only place that knows how a session switch is performed; this module must + * not reach into `lib/api` or the tab strip to do it itself. + */ +export type DesktopNotifyFocusHandler = (sessionId: string | null) => void; + +let focusHandler: DesktopNotifyFocusHandler | null = null; + +/** Register the click target's behaviour; returns the unsubscribe. The last + * registration wins, and `null` clears it — a page that unmounts must not + * leave a stale closure holding its own state alive. */ +export function registerDesktopNotifyFocusHandler( + handler: DesktopNotifyFocusHandler | null, +): () => void { + focusHandler = handler; + return () => { + if (focusHandler === handler) focusHandler = null; + }; +} + +/** Test-only handle: read the registered handler without a browser. */ +export function __testDesktopNotifyFocusHandler(): DesktopNotifyFocusHandler | null { + return focusHandler; +} + +function notificationConstructor(): typeof Notification | null { + if (typeof window === "undefined") return null; + const ctor = (window as { Notification?: typeof Notification }).Notification; + return typeof ctor === "function" ? ctor : null; +} + +/** The browser's live permission, or `"unsupported"`. Never throws. */ +export function desktopNotifyPermission(): DesktopNotifyPermission { + const ctor = notificationConstructor(); + if (!ctor) return "unsupported"; + try { + return ctor.permission; + } catch { + return "unsupported"; + } +} + +/** + * Ask the browser for permission. + * + * A `requestPermission()` that rejects (older Safari returns a promise, but a + * hostile or locked-down embedder can throw) is reported as `"denied"`, not as + * a rejection: the caller's only question is "may I show notifications", and an + * exception carries no better answer than a refusal. This function never + * rejects — a settings toggle must not be able to throw. + */ +export async function requestDesktopNotifyPermission(): Promise { + const ctor = notificationConstructor(); + if (!ctor || typeof ctor.requestPermission !== "function") return "unsupported"; + try { + const result = await ctor.requestPermission(); + return result === "granted" || result === "denied" ? result : "default"; + } catch { + return "denied"; + } +} + +/** Is the page currently in front of the user? `true` when there is no + * `document` to ask (SSR pass) is NOT claimed — without a document the answer + * is unknown, and claiming "visible" would suppress every notification. */ +function pageIsVisible(): boolean { + if (typeof document === "undefined") return false; + return document.visibilityState === "visible"; +} + +/** + * Show one notification, if the policy allows it. + * + * Returns whether a notification was actually constructed. The two ways it can + * return `false` — policy declined, or the constructor threw — are both "no + * notification appeared", which is all a caller can act on. A constructor that + * throws is swallowed rather than rethrown for the same reason + * `requestDesktopNotifyPermission` swallows: a desktop convenience must not be + * able to take down a turn. + */ +export function showDesktopNotification(input: DesktopNotificationInput): boolean { + const ctor = notificationConstructor(); + if (ctor === null) return false; + if ( + !shouldShowDesktopNotification(input.kind, { + enabled: readDesktopNotifications(), + permission: desktopNotifyPermission(), + visible: pageIsVisible(), + }) + ) { + return false; + } + try { + const notification = new ctor(input.title, { + body: input.body, + // One tag per kind per session: a repeated trigger replaces its own + // notification instead of stacking a second copy of the same news. + tag: `${input.kind}:${input.sessionId ?? "-"}`, + }); + notification.onclick = () => { + try { + window.focus(); + } catch { + // Focusing is a request, not a guarantee (and is refused outright + // outside a user gesture on some browsers). The session switch that + // follows is the part the user can actually see. + } + focusHandler?.(input.sessionId); + notification.close(); + }; + return true; + } catch { + return false; + } +} diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index ee79195d..9ab0ad6f 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -1292,6 +1292,16 @@ const en = { "settings.app.autoStartHint": "Launch the app automatically at login", "settings.app.notifications": "Desktop notifications", "settings.app.notificationsHint": "Notify when a task finishes, fails, or waits on a permission decision", + "settings.app.notificationsDenied": + "The browser blocked notifications for this site. Allow them in the browser's site settings, then turn this on again.", + "settings.app.notificationsUnsupported": + "This browser does not support the Notification API.", + "notify.turnComplete.title": "Task finished", + "notify.turnComplete.body": "A turn completed. Click to open the conversation.", + "notify.needsConfirmation.title": "Waiting for your decision", + "notify.needsConfirmation.body": "A tool needs your approval. Click to answer.", + "notify.turnError.title": "Task failed", + "notify.turnError.body": "A turn ended with an error. Click to open the conversation.", "settings.app.earlyAccess": "Join early access", "settings.app.earlyAccessHint": "Get the newest features first", "settings.app.indexing": "Accelerated indexing", @@ -2436,6 +2446,15 @@ const zh: Record = { "settings.app.autoStartHint": "登录时自动启动应用", "settings.app.notifications": "桌面通知", "settings.app.notificationsHint": "任务完成、出错、需要权限审批等阻塞状态时,发送系统通知提醒", + "settings.app.notificationsDenied": + "浏览器已拒绝本站点的通知权限。请在浏览器的站点设置中允许通知,再重新开启此开关。", + "settings.app.notificationsUnsupported": "当前浏览器不支持 Notification API。", + "notify.turnComplete.title": "任务已完成", + "notify.turnComplete.body": "一个回合已结束,点击通知可打开对应会话。", + "notify.needsConfirmation.title": "等待你的确认", + "notify.needsConfirmation.body": "有工具需要你授权,点击通知即可处理。", + "notify.turnError.title": "任务失败", + "notify.turnError.body": "一个回合以错误结束,点击通知可打开对应会话。", "settings.app.earlyAccess": "加入提前灰度", "settings.app.earlyAccessHint": "优先体验最新版本功能", "settings.app.indexing": "加速索引", diff --git a/packages/webui/webapp/lib/settings-local.ts b/packages/webui/webapp/lib/settings-local.ts index dcb09cb8..44df1f72 100644 --- a/packages/webui/webapp/lib/settings-local.ts +++ b/packages/webui/webapp/lib/settings-local.ts @@ -58,6 +58,24 @@ export const FOLLOW_UP_BEHAVIOR_KEY = "webui-follow-up-behavior"; // `SettingsModal.tsx`), so these live in the `webui-` namespace rather than // pretending to a sharing contract nobody verified. Same bare-string wire // format: the value is stored verbatim, empty string included. +/** + * SB-9 — the desktop-notifications switch. + * + * In the `webui-` namespace rather than among the four reference-shared keys + * above, for the same reason as the long-text preferences: the desktop stores + * this preference through its own OS notification service, and a bare-string + * key it reads is not a contract this client can honour. Same wire format + * (bare `"true"` / `"false"`), same `subscribe*` channel shape as + * `subscribeContextWindowUsage` — the settings page and the notification + * listener are on screen at once, so the switch must take effect without a + * reload. + * + * What the key does NOT record is the browser's own permission. That is + * `Notification.permission`, owned by the browser and revocable outside the + * page, so it is read live and never persisted: a stored "granted" would + * outlive the user clicking "block" in the site settings. + */ +export const DESKTOP_NOTIFICATIONS_KEY = "webui-desktop-notifications"; export const CUSTOM_INSTRUCTIONS_KEY = "webui-custom-instructions"; export const ABOUT_USER_KEY = "webui-about-user"; export const CODE_REVIEW_GUIDELINES_KEY = "webui-code-review-guidelines"; @@ -257,8 +275,54 @@ export function commitFollowUpBehavior( setState(value); } -// --- long-text preferences (ticket 55a) -------------------------------------- +// --- desktop notifications (SB-9) ------------------------------------------- + +/** Whether the user asked for desktop notifications. Default `false`: a + * permission prompt the page never asked for is the only way to learn + * whether the browser will grant one, and prompting on first load — before + * the user has expressed any intent — is exactly the pattern browsers + * penalise. The switch is the request. */ +export function readDesktopNotifications(): boolean { + return readFlag(DESKTOP_NOTIFICATIONS_KEY, false); +} + +type DesktopNotificationsListener = (value: boolean) => void; + +const desktopNotificationsListeners = new Set(); + +/** Subscribe to writes of `webui-desktop-notifications`. Same shape and + * same reason as `subscribeContextWindowUsage`. */ +export function subscribeDesktopNotifications( + listener: DesktopNotificationsListener, +): () => void { + desktopNotificationsListeners.add(listener); + return () => { + desktopNotificationsListeners.delete(listener); + }; +} + +export function writeDesktopNotifications(value: boolean): void { + writeFlag(DESKTOP_NOTIFICATIONS_KEY, value); + // Published unconditionally, after the write — see writeFollowUpBehavior. + for (const listener of [...desktopNotificationsListeners]) { + try { + listener(value); + } catch { + // One broken subscriber must not cost the others their update, and + // must not turn a settings toggle into an uncaught error. + } + } +} +export function commitDesktopNotifications( + setState: (value: boolean) => void, + value: boolean, +): void { + writeDesktopNotifications(value); + setState(value); +} + +// --- long-text preferences (ticket 55a) -------------------------------------- /** Read one of the three long-text preferences verbatim. Missing key, * corrupted storage, or no `window` (SSR pass) all read as the empty * string — an unset preference and an absent one are the same state to diff --git a/packages/webui/webapp/styles/settings-modal.css b/packages/webui/webapp/styles/settings-modal.css index cfd13fe7..29eece2c 100644 --- a/packages/webui/webapp/styles/settings-modal.css +++ b/packages/webui/webapp/styles/settings-modal.css @@ -43,6 +43,10 @@ .webui-settings-row strong, .webui-settings-row span { display: block; } .webui-settings-row strong { font-size: var(--size_14); font-weight: var(--weight_regular); } .webui-settings-row span { margin-top: var(--spacing_4); color: var(--text_default_tertiary); font-size: var(--size_12); } + /* SB-9:桌面通知的权限说明行。它必须读起来是「一条状态」,而不是又一段 + 描述文案,所以用状态色(--text_status_error)而不是 secondary;颜色本身 + 不足以承载语义,data-testid 同时给出机器可读的锚点。 */ + .webui-generic-row-copy .webui-generic-row-note { display: block; color: var(--text_status_error); } .webui-settings-row select { min-width: 150px; padding: var(--spacing_8); border: 1px solid var(--border_default); border-radius: var(--radius_8); background: var(--bg_default_primary); color: var(--text_default_primary); } .webui-settings-action { margin: 0 var(--spacing_20) var(--spacing_16); padding: var(--spacing_8) var(--spacing_12); border: 1px solid var(--border_default); border-radius: var(--radius_8); background: transparent; color: var(--text_default_primary); } .webui-settings-error { margin: 0 var(--spacing_20) var(--spacing_16); color: var(--text_label_danger_secondary_default); font-size: var(--size_12); } diff --git a/packages/webui/webapp/test/desktop-notify.test.ts b/packages/webui/webapp/test/desktop-notify.test.ts new file mode 100644 index 00000000..56bfbdd8 --- /dev/null +++ b/packages/webui/webapp/test/desktop-notify.test.ts @@ -0,0 +1,468 @@ +// webapp/test/desktop-notify.test.ts +// +// SB-9 — desktop notifications. +// +// The feature is deliberately split so the parts that DECIDE are pure and the +// parts that TOUCH the browser are thin. That is what makes this suite +// possible without a render harness (the standing rule, see +// settings-parity-nav.test.ts's header): the policy — which events notify, and +// when — is driven directly through `lib/desktop-notify.ts`, and the wiring +// that the policy cannot see is pinned by the tripwires at the bottom. +// +// The four guards and the defect each one exists for: +// +// 1. `shouldShowDesktopNotification` — a granted permission and an enabled +// switch must not produce a notification while the page is visible. That +// is the noise floor: the finished conversation is in the tab the user is +// looking at. A visible-page notification is the defect this batch's whole +// design is built to avoid. +// 2. `advanceDesktopNotifyCursor` — a failed turn raises ONE notification. +// The error and the settle arrive on two different SSE connections, so +// both orders are exercised; without the coalescing window a failed turn +// says "done" and "failed" about itself, or the coalescing swallows a +// legitimate completion on the next turn. +// 3. the alerts opening snapshot — a freshly opened tab must not fire up to +// 200 notifications for history it did not witness. +// 4. the settings row — a switch that reports ON while the browser has +// refused is the dishonest state. The row must show the refusal, and the +// switch must not latch. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve, dirname } from "node:path"; + +import { + advanceDesktopNotifyCursor, + shouldShowDesktopNotification, + INITIAL_NOTIFY_CURSOR, + TURN_ERROR_COALESCE_MS, + type DesktopNotifyCursor, + type DesktopNotifyObservation, +} from "../lib/desktop-notify"; + +const here = dirname(fileURLToPath(import.meta.url)); +const syncSource = readFileSync( + resolve(here, "../components/desktop-notify-sync.tsx"), + "utf8", +); +const portSource = readFileSync( + resolve(here, "../components/settings-modal-port.tsx"), + "utf8", +); +const pageSource = readFileSync(resolve(here, "../app/page.tsx"), "utf8"); + +const OFF_GRANTED_HIDDEN = { + enabled: true, + permission: "granted", + visible: false, +} as const; + +describe("shouldShowDesktopNotification — the noise floor", () => { + test("a hidden page with the switch on notifies", () => { + for (const kind of ["turn-complete", "needs-confirmation", "turn-error"] as const) { + assert.equal(shouldShowDesktopNotification(kind, OFF_GRANTED_HIDDEN), true, kind); + } + }); + + test("a visible page never notifies, whatever happened", () => { + // The rule is shared by all three kinds on purpose: each of them already + // has an on-screen face (the transcript, the blocking modal, the alerts + // badge), so a notification for a page in front of the user is duplication. + for (const kind of ["turn-complete", "needs-confirmation", "turn-error"] as const) { + assert.equal( + shouldShowDesktopNotification(kind, { ...OFF_GRANTED_HIDDEN, visible: true }), + false, + kind, + ); + } + }); + + test("MUTATION 1: dropping the visible gate flips every test above", () => { + // If someone removes `if (facts.visible) return false`, the visible-page + // case answers `true` and the previous test fails. Proven by constructing + // the mutated policy rather than by trusting the comment. + const mutated = (facts: { permission: string; enabled: boolean }): boolean => { + if (facts.permission !== "granted") return false; + if (!facts.enabled) return false; + return true; + }; + assert.equal(mutated({ permission: "granted", enabled: true }), true); + assert.notEqual( + mutated({ permission: "granted", enabled: true }), + shouldShowDesktopNotification("turn-complete", { + ...OFF_GRANTED_HIDDEN, + visible: true, + }), + ); + }); + + test("a switch that is off, or a permission that is not granted, does not notify", () => { + for (const permission of ["denied", "default", "unsupported"] as const) { + assert.equal( + shouldShowDesktopNotification("turn-complete", { + ...OFF_GRANTED_HIDDEN, + permission, + }), + false, + permission, + ); + } + assert.equal( + shouldShowDesktopNotification("turn-complete", { + ...OFF_GRANTED_HIDDEN, + enabled: false, + }), + false, + ); + }); +}); + +/** A minimal observation; the fields the cursor does not read are defaults. */ +function observation(patch: Partial = {}): DesktopNotifyObservation { + return { + runningActive: null, + sessionId: null, + authorizeRequestId: null, + alerts: [], + alertsHistorySealed: true, + nowMs: 1_000, + ...patch, + }; +} + +/** Fold a list of observations from the initial cursor. */ +function fold( + steps: readonly Partial[], +): readonly string[] { + let cursor: DesktopNotifyCursor = INITIAL_NOTIFY_CURSOR; + const raised: string[] = []; + for (const step of steps) { + const next = advanceDesktopNotifyCursor(cursor, observation(step)); + cursor = next.cursor; + if (next.decision.kind !== "none") raised.push(next.decision.kind); + } + return raised; +} + +describe("advanceDesktopNotifyCursor — one failed turn, one notification", () => { + test("a turn that runs and settles raises turn-complete", () => { + assert.deepEqual( + fold([ + { runningActive: true, sessionId: "s1" }, + { runningActive: false, sessionId: "s1" }, + ]), + ["turn-complete"], + ); + }); + + test("MUTATION 2: the settle edge is exactly true→false, not any fall", () => { + // A snapshot that arrives already idle must not announce a turn nobody + // watched finish, and a still-running turn must not announce anything. + assert.deepEqual(fold([{ runningActive: false, sessionId: "s1" }]), []); + assert.deepEqual( + fold([ + { runningActive: true, sessionId: "s1" }, + { runningActive: true, sessionId: "s1" }, + ]), + [], + ); + // And the first snapshot of a session already at rest, then a turn: + const cursor = advanceDesktopNotifyCursor( + INITIAL_NOTIFY_CURSOR, + observation({ runningActive: false, sessionId: "s1" }), + ).cursor; + assert.equal( + advanceDesktopNotifyCursor( + cursor, + observation({ runningActive: false, sessionId: "s1" }), + ).decision.kind, + "none", + ); + }); + + test("error seen first, then the settle: the completion is swallowed", () => { + assert.deepEqual( + fold([ + { runningActive: true, sessionId: "s1" }, + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + }, + { runningActive: false, sessionId: "s1", nowMs: 1_200 }, + ]), + ["turn-error"], + ); + }); + + test("the settle seen first, then the error: still exactly one notification", () => { + // The other delivery order. The alerts stream and the events stream are + // separate connections, so neither order can be assumed away. + const raised = fold([ + { runningActive: true, sessionId: "s1" }, + { runningActive: false, sessionId: "s1" }, + { + runningActive: false, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + }, + ]); + assert.deepEqual(raised, ["turn-complete", "turn-error"]); + }); + + test("MUTATION 3: without the coalescing window the error-then-settle case double-notifies", () => { + // The mutated fold is the same code with the `withinWindow` check removed. + const mutated = (): readonly string[] => { + let cursor: DesktopNotifyCursor = INITIAL_NOTIFY_CURSOR; + const raised: string[] = []; + for (const step of [ + { runningActive: true, sessionId: "s1" }, + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + }, + { runningActive: false, sessionId: "s1", nowMs: 1_200 }, + ] satisfies Partial[]) { + const next = advanceDesktopNotifyCursor(cursor, observation(step)); + cursor = next.cursor; + // The mutation: the settle is announced unconditionally. + if (next.decision.kind !== "none" || step.runningActive === false) { + raised.push("turn-complete"); + } + } + return raised; + }; + assert.deepEqual(mutated(), ["turn-complete", "turn-complete"]); + // The real fold does not. + assert.deepEqual( + fold([ + { runningActive: true, sessionId: "s1" }, + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + }, + { runningActive: false, sessionId: "s1", nowMs: 1_200 }, + ]), + ["turn-error"], + ); + }); + + test("a consumed error does not suppress the NEXT turn on the same session", () => { + const raised = fold([ + { runningActive: true, sessionId: "s1" }, + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + }, + { runningActive: false, sessionId: "s1", nowMs: 1_200 }, + { runningActive: true, sessionId: "s1", nowMs: 5_000 }, + { runningActive: false, sessionId: "s1", nowMs: 6_000 }, + ]); + assert.deepEqual(raised, ["turn-error", "turn-complete"]); + }); + + test("an error older than the coalescing window does not suppress anything", () => { + const raised = fold([ + { runningActive: true, sessionId: "s1" }, + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "a1", level: "error", msg: "boom", sessionId: "s1" }], + nowMs: 1_000, + }, + { + runningActive: false, + sessionId: "s1", + nowMs: 1_000 + TURN_ERROR_COALESCE_MS + 1, + }, + ]); + assert.deepEqual(raised, ["turn-error", "turn-complete"]); + }); + + test("the opening alerts snapshot is history, not news", () => { + // MUTATION 4: seeding. A tab opened over a busy server receives a ring + // buffer of up to 200 past alerts in its first frame; announcing them + // would fire 200 notifications for a page the user is still looking at. + assert.deepEqual( + fold([ + // Before the stream opens: nothing at all. + { runningActive: false, sessionId: "s1", alertsHistorySealed: false }, + // The opening snapshot frame, carrying two pre-load failures. + { + runningActive: false, + sessionId: "s1", + alertsHistorySealed: true, + alerts: [ + { id: "old-1", level: "error", msg: "past", sessionId: "s1" }, + { id: "old-2", level: "error", msg: "past", sessionId: "s1" }, + ], + }, + // And they stay silent on every later frame too. + { + runningActive: false, + sessionId: "s1", + alertsHistorySealed: true, + alerts: [ + { id: "old-1", level: "error", msg: "past", sessionId: "s1" }, + { id: "old-2", level: "error", msg: "past", sessionId: "s1" }, + ], + }, + ]), + [], + ); + // An EMPTY history is still history. Told by the frame kind, never by + // whether the list happens to be empty — the two look identical in a list. + assert.deepEqual( + fold([ + { runningActive: false, sessionId: "s1", alertsHistorySealed: false }, + { runningActive: false, sessionId: "s1", alertsHistorySealed: true }, + ]), + [], + ); + // …and an alert arriving AFTER the snapshot is news, once per distinct id. + assert.deepEqual( + fold([ + { + runningActive: false, + sessionId: "s1", + alertsHistorySealed: true, + alerts: [{ id: "old-1", level: "error", msg: "past", sessionId: "s1" }], + }, + { + runningActive: false, + sessionId: "s1", + alerts: [ + { id: "old-1", level: "error", msg: "past", sessionId: "s1" }, + { id: "new-1", level: "error", msg: "now", sessionId: "s1" }, + ], + }, + { + runningActive: false, + sessionId: "s1", + alerts: [ + { id: "old-1", level: "error", msg: "past", sessionId: "s1" }, + { id: "new-1", level: "error", msg: "now", sessionId: "s1" }, + ], + }, + ]), + ["turn-error"], + ); + }); + + test("a non-error alert is not a turn failure", () => { + assert.deepEqual( + fold([ + { + runningActive: true, + sessionId: "s1", + alerts: [{ id: "w1", level: "warn", msg: "slow", sessionId: "s1" }], + }, + ]), + [], + ); + }); + + test("an authorize request notifies once, and again only for a NEW request", () => { + assert.deepEqual( + fold([ + { authorizeRequestId: "r1", sessionId: "s1" }, + { authorizeRequestId: "r1", sessionId: "s1" }, + { authorizeRequestId: null, sessionId: "s1" }, + { authorizeRequestId: "r2", sessionId: "s1" }, + ]), + ["needs-confirmation", "needs-confirmation"], + ); + }); + + test("MUTATION 5: without the requestId latch a re-sent frame notifies twice", () => { + // A re-sent `needs_authorization` frame is the same request, and the server + // may deliver it more than once; the latch is what makes the count one. + // The mutation is the latch check removed: every non-null frame notifies. + const withoutLatch = (ids: readonly string[]): readonly string[] => { + let announced: string | null = null; + const raised: string[] = []; + for (const id of ids) { + if (id !== announced) { + announced = id; + raised.push(id); + } + } + return raised; + }; + // With the latch the same two frames are one request; the mutated loop over + // the same input, comparing against the cursor the way the code does when + // the cursor is NOT carried forward, is two. + const carriedForward = fold([ + { authorizeRequestId: "r1", sessionId: "s1" }, + { authorizeRequestId: "r1", sessionId: "s1" }, + ]); + assert.deepEqual(carriedForward, ["needs-confirmation"]); + // Dropping the carried-forward cursor is the mutation: the same re-sent + // frame is then indistinguishable from a new one. + let droppedCursor: DesktopNotifyCursor = INITIAL_NOTIFY_CURSOR; + let raisedTwice = 0; + for (const _step of [1, 2]) { + const next = advanceDesktopNotifyCursor( + droppedCursor, + observation({ authorizeRequestId: "r1", sessionId: "s1" }), + ); + droppedCursor = INITIAL_NOTIFY_CURSOR; // the mutation + if (next.decision.kind === "needs-confirmation") raisedTwice += 1; + } + assert.equal(raisedTwice, 2); + assert.deepEqual(withoutLatch(["r1", "r1"]), ["r1"]); + }); +}); + +describe("wiring tripwires", () => { + test("the mount point folds all three subscriptions into ONE cursor advance", () => { + // Folding them separately would let two decisions be raised in the same + // pass and lose one of them — which is how a failed turn ends up saying + // "done". + const advances = syncSource.match(/advanceDesktopNotifyCursor\(/g) ?? []; + assert.equal(advances.length, 1); + for (const field of ["state", "authorize", "alerts"]) { + assert.ok(syncSource.includes(field), `mount point must observe ${field}`); + } + assert.ok(syncSource.includes("showDesktopNotification")); + assert.ok(/export function DesktopNotifySync\(\): null/.test(syncSource)); + }); + + test("the settings row is wired, not disabled, and asks before it latches", () => { + assert.ok( + portSource.includes('testId="desktop-notifications-switch"'), + "the notifications row must render a real switch", + ); + assert.ok( + !/off\(t\("settings\.app\.notifications"\)/.test(portSource), + "the notifications row must no longer go through the disabled `off()` row", + ); + // The switch latches ONLY on a granted permission. An unconditional + // `apply(true)` is the dishonest state this row exists to avoid. + assert.ok( + /requestDesktopNotifyPermission\(\)[\s\S]{0,200}granted === "granted"/.test(portSource), + "the switch must latch only when the browser grants", + ); + // The refusal is stated, and stated in its own sentence. + assert.ok( + portSource.includes('t("settings.app.notificationsDenied")'), + "a refused permission must be reported to the user", + ); + assert.ok( + portSource.includes('data-testid="desktop-notifications-note"'), + "the refusal needs a machine-readable anchor", + ); + assert.ok(portSource.includes('t("settings.app.notificationsUnsupported")')); + }); + + test("the click path is registered by the page root, not by the notifier", () => { + assert.ok(pageSource.includes("")); + assert.ok(pageSource.includes("registerDesktopNotifyFocusHandler")); + assert.ok(pageSource.includes("api.switchSession(sessionId)")); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index ae7c7277..76b09277 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3794,6 +3794,7 @@ "packages/webui/webapp/components/context-menu.tsx", "packages/webui/webapp/components/context-meter.tsx", "packages/webui/webapp/components/conversation-usage-banner.tsx", + "packages/webui/webapp/components/desktop-notify-sync.tsx", "packages/webui/webapp/components/edited-files-card.tsx", "packages/webui/webapp/components/file-preview-pane.tsx", "packages/webui/webapp/components/file-preview.tsx", @@ -3837,6 +3838,7 @@ "packages/webui/webapp/lib/composer-draft.ts", "packages/webui/webapp/lib/composer-sent.ts", "packages/webui/webapp/lib/credential-file.ts", + "packages/webui/webapp/lib/desktop-notify.ts", "packages/webui/webapp/lib/edited-files.ts", "packages/webui/webapp/lib/effort-control.ts", "packages/webui/webapp/lib/engine-capabilities.ts", @@ -3989,6 +3991,7 @@ "packages/webui/webapp/test/context-meter-toggle.test.ts", "packages/webui/webapp/test/conversation-usage-banner.test.ts", "packages/webui/webapp/test/credential-file.test.ts", + "packages/webui/webapp/test/desktop-notify.test.ts", "packages/webui/webapp/test/dom-harness.test.ts", "packages/webui/webapp/test/edited-files-card.test.ts", "packages/webui/webapp/test/engine-capabilities-degradation.test.ts",