feat(web-shell): add action reminders and in-panel notification access (#13832)

* feat(web-shell): notify for approvals and user questions

* fix(web-shell): isolate notification help and authorization attempts

* fix(web-shell): preserve notification authorization and keyboard focus

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-12 10:51:11 +00:00
1 parent e397445a1e
commit 364593cb25
34 files changed
+2288 -54

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
# Browser notifications for user actions
[English](browser-action-notifications.md) | [简体中文](browser-action-notifications.zh-CN.md)
Status: current as of 2026-10-12. Extends turn-end notifications with action reminders and panel authorization; the linked branding and content/navigation designs still specify their unchanged behavior.
## Problem and scope
Browser task notifications currently cover turn completion and failure. A chat
waiting for tool approval or an AskUserQuestion answer can remain blocked while
the user is away without notifying them. Add both waiting states to the existing
notification setting for the current chat and mounted split-view chats.
## Design
Both interactions arrive as `permission_request` events. Reuse
`extractPendingPermission` and `isAskUserPermission` on the matching unresolved
transcript block so notification classification matches the interactive UI.
Flush buffered transcript events before observing a live permission request so
its block is available. No daemon, SDK, approval policy, or route changes are
needed; requests and click targets retain their existing session/workspace scope.
The shared notification observer handles these requests before terminal-event
prompt validation because permission requests need a `requestId`, not a
`data.promptId`. Use a key containing scope, the `permission` discriminator, and
request ID. Repeated requests and duplicate panes notify once, independently of
the turn's completion key. Foreground and disabled requests are consumed without
later backfill, matching existing terminal notification behavior. Snapshot replay
is silent, including requests already resolved in history; this change does not
add catch-up notifications for permission requests missed while disconnected.
Reuse browser permission checks, preference persistence, Web Locks/shared claims,
[branding](web-shell/web-shell-browser-notification-branding.md), localization, and
[content limits and click navigation](web-shell/web-shell-browser-notification-details.md). The title contains the session name;
the body only says approval or an answer is needed. Do not copy commands, paths,
question text, choices, or partial assistant responses into these notifications.
Clicking focuses the window and opens the captured session without answering or
approving anything. Notifications require an open, mounted chat and browser/OS
permission, as before; this is not a background push service.
## Affected files
- `packages/web-shell/client/daemon/session/turn-notification-context.ts` and its
tests: classify and deduplicate live action requests.
- `packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx`: flush the
permission projection before observation.
- `packages/web-shell/client/adapters/transcriptAdapter.ts`,
`packages/web-shell/client/utils/askUserPermission.ts`, and
`packages/web-shell/client/components/messages/toolFormatting.ts`: use explicit
`.js` import paths so the reused helpers also pass NodeNext integration checks.
- `packages/web-shell/client/browser-turn-notifications.tsx` and its tests: share
one-shot authorization and exercise browser delivery and navigation.
- `packages/web-shell/client/components/messages/BrowserNotificationControl.tsx`,
`ToolApproval.tsx`, and `AskUserQuestion.tsx`: expose notification status,
authorization, and system guidance in both action panels.
- `packages/web-shell/client/i18n.tsx` and `packages/web-shell/README.md`: explain
the two additional triggers in English and Chinese where applicable.
- `packages/web-shell/client/e2e/web-shell.browser-notifications.spec.ts`: validate
the page and mocked SSE delivery with a captured Notifications API.
## Validation and acceptance
1. A live tool approval in the background sends one localized approval reminder;
a live AskUserQuestion sends one localized answer reminder.
2. Foreground, disabled, denied, historical, resolved, and mismatched-session
requests do not notify. Repeated requests and duplicate panes do not duplicate
a reminder. Separate requests in one turn remain independent.
3. A request reminder never consumes the eventual turn-complete notification.
4. Clicking opens the original session through existing navigation and does not
submit approval or an answer. Notification bodies do not leak request details.
5. Focused unit tests and browser E2E pass, with build and typecheck verification.
E2E uses a mock daemon and Notification capture; it does not prove native OS
notification delivery or real backend approval execution.
## Open questions
None. Iframe support, disconnected catch-up, and automatic closing of displayed
reminders are outside this change.
## Notification access from action panels
ToolApproval and AskUserQuestion share a notification status button in their
top-right header in the main chat’s floating approval overlay and in split panes. Reuse
the existing scoped Popover and Button primitives. Hover, keyboard focus, and
click open an interactive popover without stealing the approval selection.
Moving into its content keeps it open; Escape closes it without rejecting or
submitting the action. Preserve focus outside the help; restore its trigger only
when focus was within the help or trigger. Keep help open during a pending
authorization request, and restore focus after its action disappears. Isolate
request-submission shortcuts while preserving navigation, unrelated controls, and
global shortcuts from the trigger.
The icon and localized status distinguish enabled with site permission, disabled,
not yet authorized, denied, and unsupported environments. Unavailable notification
contexts (including iframe and hosts that have not opted in) render no control.
The popover offers the existing enable/permission action where possible, browser
site-setting instructions when denied, and explains background/unfocused triggers,
mounted chats, click behavior, and macOS/Windows notification and Focus settings.
Both OS guides start collapsed, with a chevron beside each heading to expand or
collapse the instructions independently.
The web page cannot detect OS-level permission; do not claim that it can.
On the first eligible panel appearance, attempt authorization once per browser
site through the existing settings controller. Record the attempt before calling
the API to cover duplicate panes and Strict Mode; use an in-memory fallback when
storage is unavailable. Only attempt while permission is default, with no explicit
saved off preference. Do not retry automatically after dismissal, rejection, or
refresh. Browsers can suppress requests without a user gesture, so a manual
button remains available. No timer, synthetic click, or repeated prompt bypasses
browser rules. A successful grant enables the existing preference. Merely opening
the application still never requests permission.
Add controller tests for one-shot behavior and explicit off/denied/unavailable
states, and browser E2E for both panel controls, hover-to-content, manual grant,
enabled/denied copy, safe keyboard handling, persisted attempts, and screenshots.
Guidance follows [Apple Notifications settings](https://support.apple.com/en-gb/guide/mac-help/-mh40583/mac)
and [Microsoft Notifications and Do Not Disturb](https://support.microsoft.com/en-us/windows/experience/notifications-and-do-not-disturb-in-windows).
E2E uses an isolated mock API, not user permissions.
## Public behavior and embedder migration
The Settings row and panel control share the new “Task notifications” label.
Settings exclusions continue to match `builtin:browser-notifications`, not
the display label.
The first eligible approval/question panel attempts authorization and enables
notifications after a grant. This replaces the previous public guarantee that
permission is never requested automatically. Hosts opting in through
`browserNotifications` receive this behavior; hosts without it and iframes do
not expose the control. `defaultEnabled` still only supplies the initial
preference, and an explicitly saved off choice suppresses automatic requests.
Manual permission attempts also count toward the site attempt; dismissing one
never triggers an automatic retry, including when another panel mounts later.
Browsers may suppress a request without a gesture or return denial; denied
permission requires recovery through browser site settings.
Tool-name classification lives in the UI-free `constants/toolNames.ts` and is
re-exported by the display formatting module, keeping its display-name map out
of the daemon SDK dependency graph. Only main-chat and split-pane daemon
callers enable the tool-approval notification control; other managed approval
cards do not advertise reminders.
Related changes also cover `App.tsx` and `components/ChatPane.tsx` for control
scope; `App.test.tsx`, `SettingsMessage.dom.test.tsx`, and
`e2e/web-shell.settings-exclusions.spec.ts` for compatibility assertions;
`BrowserNotificationControl.test.tsx` and `DaemonSessionProvider.test.tsx` for
interaction/projection regressions; and `e2e/utils/mockDaemon.ts` for realistic
permission option shapes.
@@ -0,0 +1,113 @@
# 用户操作的浏览器通知
[English](browser-action-notifications.md) | [简体中文](browser-action-notifications.zh-CN.md)
状态:截至 2026-10-12 的当前设计。在回合结束通知上增加操作提醒与面板授权;下文链接的品牌及内容/导航设计继续规定其未变更的行为。
## 问题与范围
浏览器任务通知目前只覆盖回合结束和失败。用户离开页面后,聊天可能停在工具审批或
AskUserQuestion 等待回答的状态,却没有提醒。为当前聊天和仍挂载的分屏聊天增加这两种
等待提醒,共用现有通知开关。
## 设计
两种交互均来自 `permission_request` 事件。对匹配且尚未解决的 transcript 块复用
`extractPendingPermission` 和 `isAskUserPermission`,使通知分类与交互 UI 一致。
观察实时权限请求前刷新缓冲的 transcript 事件,确保相应块已可读取。不修改 daemon、
SDK、审批策略或路由;请求与点击目标保留现有的会话/工作区作用域。
共享通知观察器在终态事件的 prompt 校验前处理这类请求,因为权限请求需要的是
`requestId`,而非 `data.promptId`。去重键包含作用域、`permission` 标识和请求 ID。
重复请求和重复面板仅提醒一次,并与回合结束的去重键独立。前台或关闭通知时收到的
请求仍记为已处理,之后不补发,与现有回合通知行为一致。快照重放保持静默,包括历史
中已经解决的请求;本次不增加断线期间错过的权限请求补发。
复用浏览器权限校验、偏好持久化、Web Locks/共享认领、[品牌](web-shell/web-shell-browser-notification-branding.zh-CN.md)、多语言和[内容限制及点击导航](web-shell/web-shell-browser-notification-details.zh-CN.md)。
标题包含会话名;正文只提示需要审批或需要回答。不将命令、路径、问题文本、选项或
助手的部分回复复制到这些通知中。点击后聚焦窗口并打开捕获的原会话,不自动回答或
批准任何请求。与之前一样,通知需要网页打开、聊天仍挂载且浏览器和系统允许通知;
本次不是后台推送服务。
## 影响文件
- `packages/web-shell/client/daemon/session/turn-notification-context.ts` 及其
测试:分类并去重实时操作请求。
- `packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx`:观察前
刷新权限投影。
- `packages/web-shell/client/adapters/transcriptAdapter.ts`、
`packages/web-shell/client/utils/askUserPermission.ts` 和
`packages/web-shell/client/components/messages/toolFormatting.ts`:补全 `.js`
导入路径,使复用的辅助函数也通过 NodeNext 集成类型检查。
- `packages/web-shell/client/browser-turn-notifications.tsx` 及其测试:共享
一次性授权逻辑,覆盖浏览器发送和导航路径。
- `packages/web-shell/client/components/messages/BrowserNotificationControl.tsx`、
`ToolApproval.tsx` 和 `AskUserQuestion.tsx`:在两种操作面板中提供通知状态、
授权及系统设置指引。
- `packages/web-shell/client/i18n.tsx` 和 `packages/web-shell/README.md`:在适用
位置同步中英文说明,补充两个触发条件。
- `packages/web-shell/client/e2e/web-shell.browser-notifications.spec.ts`:结合
模拟 SSE 与 Notifications API 捕获验证页面行为。
## 验证与验收
1. 后台实时工具审批产生一次本地化审批提醒;实时 AskUserQuestion 产生一次回答提醒。
2. 前台、开关关闭、权限拒绝、历史、已解决和会话不匹配的请求不提醒。重复请求和重复
面板不会重复提醒;同一回合中的不同请求互相独立。
3. 请求提醒不会消费后续的回合结束通知。
4. 点击通过现有导航打开原会话,不提交审批或答案;通知正文不泄露请求详情。
5. 聚焦单测和浏览器 E2E 通过,并验证构建与类型检查。E2E 使用模拟 daemon 和
Notification 捕获,不能证明原生系统通知展示或真实后端审批执行。
## 待定问题
无。iframe 支持、断线补发以及已展示提醒的自动关闭均不在本次范围内。
## 操作面板中的通知入口
ToolApproval 与 AskUserQuestion 在标题右上角共用通知状态按钮,覆盖主聊天浮层审批和分屏。
复用现有带作用域的 Popover 与 Button。鼠标悬停、键盘聚焦或点击均可打开可交互的说明,
且不抢走审批选项的焦点;鼠标移入内容后保持打开。Escape 只关闭说明,不拒绝或提交
请求。隔离说明中的键盘事件,避免触发面板快捷键。
图标与本地化状态区分已开启且站点已授权、已关闭、未授权、已拒绝和环境不支持。
没有通知上下文(包括 iframe 或未接入通知的宿主)不渲染该入口。在可用时提供现有
开启/授权操作;拒绝后说明如何修改浏览器站点设置。说明涵盖后台/失焦的触发条件、
聊天需保持挂载、点击行为,以及 macOS/Windows 的通知和专注设置。两种系统指引默认
收起,标题旁带箭头,可独立展开或收起。网页无法检测系统权限,不声称可以检测。
首次符合条件的面板出现时,通过现有设置控制器为当前浏览器站点尝试一次授权。
调用 API 前记下尝试,避免分屏和 Strict Mode 重复触发;存储不可用时使用内存去重。
仅在权限为 default 且用户没有明确保存关闭选择时尝试。关闭授权提示、拒绝或刷新后
均不自动重试。浏览器可能拦截缺少用户手势的申请,因此始终保留手动按钮。不通过定时、
模拟点击或重复申请绕过浏览器规则;成功授权后开启现有偏好。仅打开应用仍不会申请。
增加控制器单测,覆盖一次性尝试与明确关闭/拒绝/不可用状态;浏览器 E2E 覆盖两个
面板的入口、从图标移动到说明、手动授权、已开启/拒绝文案、键盘隔离、尝试记录持久化
和截图。系统指引依据 [Apple 通知设置](https://support.apple.com/en-gb/guide/mac-help/-mh40583/mac)
与 [Microsoft 通知/请勿打扰文档](https://support.microsoft.com/en-us/windows/experience/notifications-and-do-not-disturb-in-windows)。
E2E 使用隔离的模拟 API,不操作用户权限。
授权申请期间说明保持打开,授权操作消失后恢复焦点。Escape 关闭说明时,保留说明之外的原有焦点;仅说明或入口持有焦点时才恢复至入口。拦截会提交请求的快捷键,同时保留导航、其他控件及入口上的全局快捷键。
## 公开行为与接入迁移
设置项及面板入口统一使用“任务通知”名称;设置排除项仍按
`builtin:browser-notifications` 标识匹配,不按显示文案匹配。
首次符合条件的审批/问答面板会主动尝试授权,并在授权成功后开启通知,
这取代了此前“不会自动申请权限”的公开行为。宿主传入 `browserNotifications`
即启用该流程;未接入或 iframe 中不显示入口。`defaultEnabled` 仍只指定
未保存偏好时的初始值,用户明确关闭通知时不会自动申请。浏览器可能抑制
非用户手势的申请或返回拒绝;拒绝后需要通过浏览器站点设置恢复。
手动授权申请也计入站点的一次申请记录;关闭申请后,不会因其他面板随后挂载而自动重试。
问答工具名称判断放在不依赖 UI 的 `constants/toolNames.ts`,由展示格式化模块
重新导出,避免 daemon SDK 间接引入展示名称映射。工具审批中的通知入口仅由
主聊天和分屏的 daemon 请求调用方开启,其他管理审批卡片不显示该入口。
实现还涉及 `App.tsx`、`components/ChatPane.tsx` 的入口作用域、
`App.test.tsx`、`SettingsMessage.dom.test.tsx` 和
`e2e/web-shell.settings-exclusions.spec.ts` 的兼容断言,以及
`BrowserNotificationControl.test.tsx`、`DaemonSessionProvider.test.tsx` 的交互/投影
回归和 `e2e/utils/mockDaemon.ts` 的真实权限选项形状。
@@ -2,6 +2,8 @@
[English](web-shell-browser-notification-branding.md) | [简体中文](web-shell-browser-notification-branding.zh-CN.md)
Status: current as of 2026-10-12 for branding, public options, preference precedence, storage fallback, and click ownership. The [action reminder design](../browser-action-notifications.md) supersedes only the earlier task label, turn-end-only triggers, and never-auto-request permission provision; its first-panel authorization rule is now part of the integration contract.
## Problem and scope
Before this PR, #11398 used the fixed `Qwen Code` title and passed no icon to Notification. This PR introduces the bundled PNG and the requested QwenCode spelling, alongside configurable branding.
@@ -12,7 +14,7 @@ The [content and navigation design](web-shell-browser-notification-details.md) d
Expose `browserNotifications?: WebShellBrowserNotificationsOptions` on `WebShellWithProviders` and its `StandaloneWebShell` alias. The options contain optional `appName` and `iconUrl` strings, plus `defaultEnabled?: boolean` (false by default). Missing or whitespace-only branding values fall back independently to QwenCode and the bundled PNG. The title remains application name · session title, or only the application name when there is no session title. Pass the trimmed icon URL directly to Notification.icon; no proxy, preload, authentication headers, or automatic retry is added. The browser fetches the image under its security policies; a failed custom image does not guarantee the default icon is shown.
Omitting the options preserves the embedded entry's existing behavior without automatic browser notifications. Passing an object, including an empty object, connects the notification provider above the daemon session provider and exposes the existing UI setting. The initial browser-local preference uses defaultEnabled only when no stored choice exists. If storage is unreadable, start off rather than assuming a missing preference; the user may still explicitly enable notifications temporarily. Explicit stored true or false always wins, including after reload; removing the stored choice restores that instance's initial default. Changing defaultEnabled after mounting does not overwrite the current choice. Permission is never requested automatically, even when the default is on. Changing branding values or toggling the options between an object and undefined preserves the mounted session and notification preference. Omission disables notification contexts and delivery; returning to an object restores the same instance. The initial default is read at mount even if integration is initially omitted. The built-in main.tsx explicitly sets defaultEnabled to true while keeping the default name and icon. Thus the built-in development and installed qwen serve pages start enabled for a fresh browser site, but delivery still requires browser permission and the existing background/unfocused conditions.
Omitting the options preserves the embedded entry's existing behavior without automatic browser notifications. Passing an object, including an empty object, connects the notification provider above the daemon session provider and exposes the existing UI setting. The initial browser-local preference uses defaultEnabled only when no stored choice exists. If storage is unreadable, start off rather than assuming a missing preference; the user may still explicitly enable notifications temporarily. Explicit stored true or false always wins, including after reload; removing the stored choice restores that instance's initial default. Changing defaultEnabled after mounting does not overwrite the current choice. Permission requests follow the [action reminder design](../browser-action-notifications.md#public-behavior-and-embedder-migration): the first eligible approval/question panel attempts authorization once, while an explicit off preference suppresses it. Merely enabling the default or opening the application does not request permission. Changing branding values or toggling the options between an object and undefined preserves the mounted session and notification preference. Omission disables notification contexts and delivery; returning to an object restores the same instance. The initial default is read at mount even if integration is initially omitted. The built-in main.tsx explicitly sets defaultEnabled to true while keeping the default name and icon. Thus the built-in development and installed qwen serve pages start enabled for a fresh browser site, but delivery still requires browser permission and the existing background/unfocused conditions.
The low-level WebShell component does not gain this prop: its daemon providers are owned by the caller and sit outside its subtree, so an internal notifier could not observe their events. No new CLI option, daemon setting, public daemon protocol, or low-level notification provider API is introduced. Iframe and server-rendered paths continue without browser notifications.
@@ -2,6 +2,8 @@
[English](web-shell-browser-notification-branding.md) | [简体中文](web-shell-browser-notification-branding.zh-CN.md)
状态:截至 2026-10-12,品牌、公开选项、偏好优先级、存储兜底及点击归属仍为当前设计。[操作提醒设计](../browser-action-notifications.zh-CN.md)仅取代旧任务名称、仅回合结束的触发范围及“从不自动申请权限”条款;首次面板自动申请授权现已属于接入行为。
## 问题与范围
本 PR 之前,#11398 使用固定的 `Qwen Code` 标题,不向 Notification 传入图标。本 PR 引入随包 PNG 和用户指定的 QwenCode 拼写,并支持品牌配置。
@@ -12,7 +14,7 @@
在 WebShellWithProviders 及其 StandaloneWebShell 别名上提供 `browserNotifications?: WebShellBrowserNotificationsOptions`。配置包含可选字符串 appName 和 iconUrl,以及默认值为 false 的可选布尔值 defaultEnabled。名称和图标缺失或仅含空白时分别回退到 QwenCode 和随包 PNG。标题保持“应用名称 · 会话标题”,没有会话标题时只显示应用名称。图片 URL 去掉首尾空白后直接传给 Notification.icon,不增加代理、预加载、认证请求头或自动重试。浏览器按自身安全策略获取图片,自定义图片加载失败不保证自动显示默认图标。
不传配置时保留嵌入入口原有行为,不自动接入浏览器通知。传入对象(包括空对象)时,在 daemon session provider 上方接入通知 provider,并展示已有的 UI 设置。浏览器本地偏好仅在没有保存选择时使用 defaultEnabled。存储不可读时默认关闭,不将其视为没有偏好;用户仍可显式临时开启通知。已保存的 true 或 false 始终优先,刷新后也保留;删除保存值时恢复该实例的初始默认值。挂载后修改 defaultEnabled 不覆盖当前选择。即使默认开启,也不自动申请浏览器权限。修改品牌配置或在对象与 undefined 之间切换配置时,保留已挂载会话和通知偏好。不传配置会停用通知上下文及发送,重新传入对象恢复同一实例。初始默认值在挂载时读取,即使初始未接入通知也是如此。内置 main.tsx 显式传入 defaultEnabled 为 true,并继续使用默认名称和图标。因此内置开发页面和安装后的 qwen serve 页面在新的浏览器站点上默认开启,但实际通知仍需要浏览器授权,并符合已有后台或失焦条件。
不传配置时保留嵌入入口原有行为,不自动接入浏览器通知。传入对象(包括空对象)时,在 daemon session provider 上方接入通知 provider,并展示已有的 UI 设置。浏览器本地偏好仅在没有保存选择时使用 defaultEnabled。存储不可读时默认关闭,不将其视为没有偏好;用户仍可显式临时开启通知。已保存的 true 或 false 始终优先,刷新后也保留;删除保存值时恢复该实例的初始默认值。挂载后修改 defaultEnabled 不覆盖当前选择。权限申请遵循[操作提醒设计](../browser-action-notifications.zh-CN.md#公开行为与接入迁移):首次符合条件的审批/问答面板尝试一次授权,明确关闭的偏好会阻止申请。仅默认开启或打开应用不会申请权限。修改品牌配置或在对象与 undefined 之间切换配置时,保留已挂载会话和通知偏好。不传配置会停用通知上下文及发送,重新传入对象恢复同一实例。初始默认值在挂载时读取,即使初始未接入通知也是如此。内置 main.tsx 显式传入 defaultEnabled 为 true,并继续使用默认名称和图标。因此内置开发页面和安装后的 qwen serve 页面在新的浏览器站点上默认开启,但实际通知仍需要浏览器授权,并符合已有后台或失焦条件。
低层 WebShell 组件不增加该属性:它的 daemon providers 由调用方管理且在组件子树之外,内部通知组件无法观察那些事件。不增加 CLI 参数、daemon 设置、公共 daemon 协议或低层通知 provider API。iframe 和服务端渲染路径继续不使用浏览器通知。
@@ -2,7 +2,9 @@
[English](web-shell-browser-notification-details.md) | [简体中文](web-shell-browser-notification-details.zh-CN.md)
Following the base implementation in #11398, this enhancement lets users identify the session behind a notification, preview this turn's reply, and click to return to that session. Permissions, background triggers, and deduplication remain those of the base implementation; the built-in entry now defaults the preference on as described in the branding design. The public Web Shell branding options are described in the [branding design](web-shell-browser-notification-branding.md); no daemon route or wire field is added.
Status: current as of 2026-10-12 for turn-end content limits, icons, and session navigation. The [action reminder design](../browser-action-notifications.md) extends triggers and updates the task label. Authorization follows the amended [branding integration contract](web-shell-browser-notification-branding.md#configuration-and-integration).
Following the base implementation in #11398, this enhancement lets users identify the session behind a notification, preview this turn's reply, and click to return to that session. For turn-end reminders, background triggers and deduplication remain those of the base implementation; the built-in entry now defaults the preference on as described in the branding design. The public Web Shell branding options are described in the [branding design](web-shell-browser-notification-branding.md); no daemon route or wire field is added.
## Content and icon
@@ -2,7 +2,9 @@
[English](web-shell-browser-notification-details.md) | [简体中文](web-shell-browser-notification-details.zh-CN.md)
基础版合入 #11398 后,本次增强让用户直接辨认通知对应的会话、查看本轮回复摘录,并点击返回对应会话。权限、后台触发和去重规则沿用基础版,内置入口现按品牌设计默认开启偏好。不增加 daemon 路由或协议字段;新增的公共 Web Shell 品牌选项见[品牌设计](web-shell-browser-notification-branding.zh-CN.md)。
状态:截至 2026-10-12,回合结束通知的内容限制、图标及会话导航仍为当前设计。[操作提醒设计](../browser-action-notifications.zh-CN.md)扩展触发条件并更新任务名称;授权遵循已修订的[品牌接入行为](web-shell-browser-notification-branding.zh-CN.md#配置与接入)。
基础版合入 #11398 后,本次增强让用户直接辨认通知对应的会话、查看本轮回复摘录,并点击返回对应会话。回合结束通知的后台触发和去重规则沿用基础版,内置入口现按品牌设计默认开启偏好。不增加 daemon 路由或协议字段;新增的公共 Web Shell 品牌选项见[品牌设计](web-shell-browser-notification-branding.zh-CN.md)。
## 内容与图标
@@ -2,7 +2,7 @@
[English](web-shell-browser-turn-notifications.md) | [简体中文](web-shell-browser-turn-notifications.zh-CN.md)
Status: historical design for the 2026-09-08 baseline. The provisions below about being disabled by default, generic copy, focusing the window only on click, and adding no public API describe the baseline at #11398. For current behavior, see [notification content and session navigation](web-shell-browser-notification-details.zh-CN.md) and [configurable branding](web-shell-browser-notification-branding.zh-CN.md), which supersede this baseline. English counterparts are available through the language links in those two designs.
Status: historical design for the 2026-09-08 baseline. The provisions below about being disabled by default, generic copy, focusing the window only on click, and adding no public API describe the baseline at #11398. For the later turn-end behavior, see [notification content and session navigation](web-shell-browser-notification-details.md) and [configurable branding](web-shell-browser-notification-branding.md), which supersede this baseline. For the 2026-10-12 task label, approval/question reminders, and replacement of the never-auto-request permission guarantee, see the [action reminder design](../browser-action-notifications.md).
## Decisions and scope
@@ -2,7 +2,7 @@
[English](web-shell-browser-turn-notifications.md) | [简体中文](web-shell-browser-turn-notifications.zh-CN.md)
状态:2026-09-08 基础版历史设计。以下默认关闭、通用文案、点击仅聚焦、不增加公共 API 等条款描述 #11398 当时的基线;当前行为已由[通知内容与会话导航](web-shell-browser-notification-details.zh-CN.md)及[可配置品牌](web-shell-browser-notification-branding.zh-CN.md)取代,英文对应文档通过两份设计的语言链接提供。
状态:2026-09-08 基础版历史设计。以下默认关闭、通用文案、点击仅聚焦、不增加公共 API 等条款描述 #11398 当时的基线;后续回合结束行为已由[通知内容与会话导航](web-shell-browser-notification-details.zh-CN.md)及[可配置品牌](web-shell-browser-notification-branding.zh-CN.md)取代,英文对应文档通过两份设计的语言链接提供。 2026-10-12 的任务通知名称、审批/问答提醒及对“从不自动申请权限”承诺的取代,见[操作提醒设计](../browser-action-notifications.zh-CN.md)。
## 决策与范围
+6 -7
View File
@@ -168,14 +168,13 @@ Tailwind 或额外引入全局 CSS。
## 浏览器任务通知
通过 `qwen serve` 打开的独立 Web Shell 可在 **Settings → UI → 浏览器任务通知**
管理提醒。内置 `main.tsx` 显式设置默认开启;用户已保存的关闭选择优先。仅在用户点击后申请浏览器授权;偏好保存在当前浏览器站点,
通过 `qwen serve` 打开的独立 Web Shell 可在 **Settings → UI → 任务通知**
管理提醒。内置 `main.tsx` 显式设置默认开启;用户已保存的关闭选择优先。可在设置或审批/提问面板右上角的通知图标中授权;偏好保存在当前浏览器站点,
不写入 daemon 或 workspace 设置,同源标签页之间同步。
页面在后台或窗口失焦时,当前聊天及 Split View 中仍挂载的聊天在回合结束或失败后
可以发送系统通知。取消回合、初次加载历史和前台已处理的回合保持静默。通知显示会话标题(最多 60 个 Unicode 码点)、回合状态,以及带“提问 / 回复”标签的本轮提问(最多 80 个码点)和回复(最多 120 个码点)纯文本摘录。
标题显示 QwenCode · 会话名,尚未生成时使用本轮问题首行,两者均缺失时显示 QwenCode;业务文案不额外添加地址。通知携带 Qwen Code 图标,实际显示位置由浏览器和操作系统决定,不能替换 macOS 上 Chrome 自身的标志。摘要直接截取文本,不额外调用模型;提问或回复缺失时省略对应行,两者均缺失时只显示状态。失败时可显示本轮提问,不展示错误详情或部分回复。系统可能进一步截短通知正文。
通知内容可能显示在系统通知中心或锁屏上,遵循系统的预览设置;点击通知尝试聚焦原窗口并在主聊天中打开对应会话,退出设置页或分屏;已在目标会话时不会重新加载。
页面在后台或窗口失焦时,当前聊天及 Split View 中仍挂载的聊天在回合结束、失败、需要工具审批或等待 AskUserQuestion 回答时可以发送系统通知。取消回合、初次加载历史和前台已处理的回合或请求保持静默。工具审批和提问提醒只显示会话标题与待处理状态,不展示命令、路径、问题、选项或部分回复;同一请求只提醒一次,不影响后续回合结束提醒。快照重放不补发权限或提问提醒,包括断线期间错过的请求。回合结束通知显示会话标题(最多 60 个 Unicode 码点)、回合状态,以及带“提问 / 回复”标签的本轮提问(最多 80 个码点)和回复(最多 120 个码点)纯文本摘录。
标题显示 QwenCode · 会话名;回合结束通知在会话名尚未生成时使用本轮问题首行,两者均缺失时显示 QwenCode。审批或提问提醒缺少会话名时只显示 QwenCode。业务文案不额外添加地址。通知携带 Qwen Code 图标,实际显示位置由浏览器和操作系统决定,不能替换 macOS 上 Chrome 自身的标志。摘要直接截取文本,不额外调用模型;提问或回复缺失时省略对应行,两者均缺失时只显示状态。失败时可显示本轮提问,不展示错误详情或部分回复。系统可能进一步截短通知正文。
通知内容可能显示在系统通知中心或锁屏上,遵循系统的预览设置;点击通知尝试聚焦原窗口并在主聊天中打开对应会话,退出设置页或分屏;已在目标会话时不会重新加载。点击审批或提问提醒不会自动批准请求或提交答案。
需要支持 Notifications API 的桌面浏览器以及 HTTPS 或可信的 localhost 环境。
支持 Web Locks 且存储可用时,同源标签页协调去重;否则退化为页面内去重及相同 tag
@@ -198,7 +197,7 @@ Tailwind 或额外引入全局 CSS。
传入 `{}` 时使用默认 `QwenCode` 名称和随包图标;名称和图标均可单独省略,空白值也回退默认。标题显示“应用名称 · 会话标题”。图片 URL 由浏览器直接加载,可使用 HTTPS CDN 地址;加载失败不保证自动回退到默认图标。
`defaultEnabled` 默认 `false`;设为 `true` 时仅对没有保存通知偏好的浏览器站点默认开启。用户明确开启或关闭的选择优先,刷新后也保留;挂载后修改默认值不会覆盖当前选择。不传 `browserNotifications` 时保持嵌入入口原有行为,不接入通知。即使默认开启,也不会自动申请权限,用户仍需在 Settings → UI 中允许浏览器通知。修改品牌值不会重新加载当前会话。通知点击只导航所属实例,并遵守其当前锁定工作区。
`defaultEnabled` 默认 `false`;设为 `true` 时仅对没有保存通知偏好的浏览器站点默认开启。用户明确开启或关闭的选择优先,刷新后也保留;挂载后修改默认值不会覆盖当前选择。不传 `browserNotifications` 时保持嵌入入口原有行为,不接入通知。仅挂载不会申请权限;首次出现审批或提问面板时,若权限尚未决定且没有明确保存关闭选择,会为此浏览器站点自动尝试一次授权。浏览器可能拦截没有用户手势的申请;面板右上角的通知图标可悬停或点击查看状态、手动授权,以及 macOS/Windows 系统设置指引。不会重复自动申请,刷新后也保留尝试记录;存储不可用时只在当前挂载实例内去重。网页无法检测系统通知是否开启。修改品牌值不会重新加载当前会话。通知点击只导航所属实例,并遵守其当前锁定工作区。
低层 `WebShell` 的 daemon providers 由宿主管理,不支持这个配置属性;qwen serve 内置页面继续使用默认品牌。本配置仅影响通知,不改变侧边栏品牌、Chrome 来源地址或浏览器标志。
+1
View File
@@ -24344,6 +24344,7 @@ describe('App session callbacks', () => {
setEnabled: vi.fn(),
refreshPermission: vi.fn(),
syncLanguage,
requestPermissionOnce: vi.fn(),
});
const { rerender } = renderApp();
await flush();
+1
View File
@@ -22175,6 +22175,7 @@ export function App({
className={styles.approvalOverlay}
>
<ToolApproval
showBrowserNotifications
disabled={isExitPlanApprovalRequest(pendingToolApproval) && modeControlsBusy}
planExecutionMode={connection.planExecutionMode}
request={pendingToolApproval}
@@ -3,7 +3,7 @@ import type {
ContentBlock,
PermissionRequest,
PermissionOptionKind,
} from './types';
} from './types.js';
type PermissionTranscriptBlock = Extract<
DaemonTranscriptBlock,
@@ -5,6 +5,7 @@
*/
import { readReportedArtifacts } from './reported-artifacts.js';
import { isAskUserQuestionToolName } from '../constants/toolNames.js';
import {
isTaskExecutionMode,
parseDaemonBackgroundTurn,
@@ -2066,9 +2067,7 @@ function normalizeToolContent(
}
function isAskUserQuestionBlock(block: DaemonToolTranscriptBlock): boolean {
if (!block.toolName) return false;
const normalized = block.toolName.toLowerCase();
return normalized === 'ask_user_question' || normalized === 'askuserquestion';
return !!block.toolName && isAskUserQuestionToolName(block.toolName);
}
function getToolContentText(
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { webcrypto } from 'node:crypto';
import { act, useContext, type ReactNode } from 'react';
import { act, StrictMode, useContext, useEffect, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -118,6 +118,240 @@ afterEach(() => {
});
describe('browser task notifications', () => {
it('settles a first-panel grant when the panel mounts under Strict Mode', async () => {
FakeNotification.permission = 'default';
function FirstPanel() {
const request = useBrowserNotificationSettings()?.requestPermissionOnce;
useEffect(() => request?.(), [request]);
return null;
}
const capture: Capture = {};
render(capture, (node) => (
<StrictMode>
<BrowserTurnNotifications language="en">
<FirstPanel />
{node}
</BrowserTurnNotifications>
</StrictMode>
));
await act(async () => {});
expect(FakeNotification.requestPermission).toHaveBeenCalledOnce();
expect(capture.settings!.pending).toBe(false);
expect(capture.settings!.enabled).toBe(true);
expect(capture.settings!.permission).toBe('granted');
});
it('attempts first-panel authorization once across panels and remounts, with manual retry', async () => {
FakeNotification.permission = 'default';
FakeNotification.requestPermission.mockResolvedValue('default');
const first: Capture = {};
const root = render(first);
await act(async () => {
first.settings!.requestPermissionOnce();
first.settings!.requestPermissionOnce();
});
expect(FakeNotification.requestPermission).toHaveBeenCalledOnce();
expect(first.settings!.pending).toBe(false);
expect(first.settings!.enabled).toBe(false);
act(() => root.unmount());
const next: Capture = {};
render(next);
await act(async () => next.settings!.requestPermissionOnce());
expect(FakeNotification.requestPermission).toHaveBeenCalledOnce();
FakeNotification.requestPermission.mockImplementation(async () => {
FakeNotification.permission = 'granted';
return 'granted';
});
await act(async () => next.settings!.setEnabled(true));
expect(FakeNotification.requestPermission).toHaveBeenCalledTimes(2);
expect(next.settings!.enabled).toBe(true);
expect(next.settings!.permission).toBe('granted');
});
it('does not auto-request after an in-flight manual prompt is dismissed', async () => {
FakeNotification.permission = 'default';
let dismiss!: (permission: NotificationPermission) => void;
FakeNotification.requestPermission.mockImplementation(
() =>
new Promise((resolve) => {
dismiss = resolve;
}),
);
const capture: Capture = {};
const root = render(capture);
act(() => {
void capture.settings!.setEnabled(true);
});
function Panel() {
const request = useBrowserNotificationSettings()?.requestPermissionOnce;
useEffect(() => request?.(), [request]);
return null;
}
act(() =>
root.render(
<BrowserTurnNotifications language="en">
<Panel />
<Probe capture={capture} />
</BrowserTurnNotifications>,
),
);
expect(capture.settings!.pending).toBe(true);
await act(async () => dismiss('default'));
expect(FakeNotification.requestPermission).toHaveBeenCalledOnce();
expect(capture.settings!.pending).toBe(false);
});
it.each(['off', 'denied', 'granted', 'unavailable'] as const)(
'does not auto-request notification authorization when %s',
async (reason) => {
FakeNotification.permission = reason === 'denied' ? 'denied' : 'default';
if (reason === 'granted') FakeNotification.permission = 'granted';
if (reason === 'off')
localStorage.setItem(BROWSER_NOTIFICATIONS_STORAGE_KEY, 'false');
if (reason === 'unavailable') vi.stubGlobal('isSecureContext', false);
const capture: Capture = {};
render(capture);
await act(async () => capture.settings!.requestPermissionOnce());
expect(FakeNotification.requestPermission).not.toHaveBeenCalled();
expect(capture.settings!.enabled).toBe(false);
},
);
it('enables notifications after a first-panel grant and deduplicates without storage', async () => {
FakeNotification.permission = 'default';
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('storage blocked');
});
const capture: Capture = {};
render(capture);
await act(async () => {
capture.settings!.requestPermissionOnce();
capture.settings!.requestPermissionOnce();
});
expect(FakeNotification.requestPermission).toHaveBeenCalledOnce();
expect(capture.settings!.enabled).toBe(true);
expect(capture.settings!.permission).toBe('granted');
expect(capture.settings!.persistent).toBe(false);
});
it.each([
[
'approval',
'This session needs your approval. Return to review the request.',
],
['question', 'This session is waiting for your answer. Return to respond.'],
] as const)(
'delivers a %s reminder and opens its captured session without consuming completion',
async (attention, body) => {
window.localStorage.setItem(BROWSER_NOTIFICATIONS_STORAGE_KEY, 'true');
const capture: Capture = {};
render(capture);
attach(capture);
const target = {
sessionId: 'session',
sessionContext: { kind: 'workspace' as const, cwd: '/workspace' },
};
const open = vi.fn();
capture.navigationTarget!.addEventListener('qwen:open-session', open);
const event = {
type: 'permission_request',
data: { sessionId: 'session', requestId: 'prompt' },
};
await act(async () => {
capture.observer!.observe('scope', 'session', event, false, {
attention,
sessionTitle: 'Review changes',
target,
});
await vi.waitFor(() => expect(notifications).toHaveLength(1));
});
expect(notifications[0]?.title).toBe('QwenCode · Review changes');
expect(notifications[0]?.options.body).toBe(body);
const focus = vi.spyOn(window, 'focus').mockImplementation(() => {});
notifications[0]?.onclick?.();
expect(focus).toHaveBeenCalledOnce();
expect((open.mock.calls[0]![0] as CustomEvent).detail).toEqual(target);
expect(notifications[0]?.close).toHaveBeenCalledOnce();
await settle(capture);
expect(notifications).toHaveLength(2);
expect(notifications[1]?.options.tag).not.toBe(
notifications[0]?.options.tag,
);
},
);
it.each(['foreground', 'disabled', 'denied', 'history'] as const)(
'keeps an action request silent when %s, without later backfill',
async (reason) => {
if (reason !== 'disabled')
window.localStorage.setItem(BROWSER_NOTIFICATIONS_STORAGE_KEY, 'true');
if (reason === 'denied') FakeNotification.permission = 'denied';
if (reason === 'foreground')
vi.mocked(document.hasFocus).mockReturnValue(true);
const capture: Capture = {};
render(capture);
attach(capture);
const event = {
type: 'permission_request',
data: { sessionId: 'session', requestId: 'quiet' },
};
await act(async () => {
capture.observer!.observe(
'scope',
'session',
event,
reason === 'history',
{ attention: 'question' },
);
});
expect(notifications).toHaveLength(0);
if (reason === 'history') return;
FakeNotification.permission = 'granted';
vi.mocked(document.hasFocus).mockReturnValue(false);
await act(() => capture.settings!.setEnabled(true));
await act(async () => {
capture.observer!.observe('scope', 'session', event, false, {
attention: 'question',
});
});
await settle(capture, 'quiet-marker');
expect(notifications).toHaveLength(1);
expect(notifications[0]?.options.body).toBe('This turn has completed.');
},
);
it('localizes action reminders in Chinese', async () => {
window.localStorage.setItem(BROWSER_NOTIFICATIONS_STORAGE_KEY, 'true');
const capture: Capture = {};
render(capture, (node) => (
<BrowserTurnNotifications language="zh-CN">
{node}
</BrowserTurnNotifications>
));
attach(capture);
await act(async () => {
for (const attention of ['approval', 'question'] as const) {
capture.observer!.observe(
'scope',
'session',
{
type: 'permission_request',
data: { sessionId: 'session', requestId: attention },
},
false,
{ attention },
);
}
await vi.waitFor(() => expect(notifications).toHaveLength(2));
});
expect(
notifications.map((notification) => notification.options.body),
).toEqual([
'此会话需要你的审批,请返回查看请求。',
'此会话正在等待你的回答,请返回处理。',
]);
});
it('defaults on only when configured and preserves an explicit off choice after remount', async () => {
const capture: Capture = {};
const wrapper = (node: ReactNode) => (
@@ -25,6 +25,8 @@ import {
export const BROWSER_NOTIFICATIONS_STORAGE_KEY =
'qwen-code-web-shell-browser-notifications';
const CLAIMS_STORAGE_KEY = 'qwen-code-web-shell-notification-claims';
const AUTH_ATTEMPT_STORAGE_KEY =
'qwen-code-web-shell-notification-authorization-attempted';
const MAX_CLAIMS = 1024;
const NOTIFICATION_ICON_URL = new URL(
'./assets/qwen-code-notification.png',
@@ -32,7 +34,7 @@ const NOTIFICATION_ICON_URL = new URL(
).href;
export interface WebShellBrowserNotificationsOptions {
/** Initial preference when none is saved. Defaults to false; never requests permission automatically. */
/** Initial preference when none is saved. Defaults to false; mounting alone never requests permission. */
defaultEnabled?: boolean;
/** Application name prefixed to notification titles. Defaults to QwenCode. */
appName?: string;
@@ -56,6 +58,7 @@ interface BrowserNotificationSettings {
persistent: boolean;
error: boolean;
setEnabled(enabled: boolean): Promise<void>;
requestPermissionOnce(): void;
refreshPermission(): void;
syncLanguage(language: WebShellLanguage): void;
}
@@ -129,6 +132,7 @@ function StandaloneNotifications({
const enabledRef = useRef(preference.enabled);
const version = useRef(0);
const mounted = useRef(true);
const authorizationAttempted = useRef(false);
const notifyRef = useRef<(turn: TurnNotification) => void>(() => {});
const [observer] = useState(() =>
createTurnNotificationObserver((turn) => notifyRef.current(turn)),
@@ -183,6 +187,12 @@ function StandaloneNotifications({
const storedBeforeRequest = readStoredPreference();
let nextPermission = permission();
if (nextPermission === 'default') {
authorizationAttempted.current = true;
try {
window.localStorage.setItem(AUTH_ATTEMPT_STORAGE_KEY, 'true');
} catch {
// The mounted shell still remembers manual authorization attempts.
}
setPending(true);
try {
nextPermission = await window.Notification.requestPermission();
@@ -204,6 +214,34 @@ function StandaloneNotifications({
[savePreference, defaultEnabled],
);
const requestPermissionOnce = useCallback(() => {
if (
!activeRef.current ||
authorizationAttempted.current ||
permission() !== 'default' ||
readStoredPreference() === 'false'
)
return;
authorizationAttempted.current = true;
try {
if (window.localStorage.getItem(AUTH_ATTEMPT_STORAGE_KEY) === 'true')
return;
window.localStorage.setItem(AUTH_ATTEMPT_STORAGE_KEY, 'true');
} catch {
// Storage restrictions limit one-shot authorization to this mounted shell.
}
// Wait for Strict Mode's initial effect cleanup/setup before requesting.
queueMicrotask(() => {
if (
mounted.current &&
activeRef.current &&
permission() === 'default' &&
readStoredPreference() !== 'false'
)
void setEnabled(true);
});
}, [setEnabled]);
notifyRef.current = (turn) => {
const request = version.current;
const canShow = () =>
@@ -318,6 +356,7 @@ function StandaloneNotifications({
pending,
error,
setEnabled,
requestPermissionOnce,
refreshPermission,
syncLanguage,
}
@@ -1567,6 +1567,7 @@ describe('ChatPane', () => {
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
render();
expect(testid('pane-approval')).not.toBeNull();
expect(latestToolApprovalProps?.showBrowserNotifications).toBe(true);
// The streaming status and the editor share the approval-hidden wrapper,
// so neither lingers below the dialog.
expect(testid('pane-streaming')?.parentElement?.className).toContain(
@@ -1784,6 +1784,7 @@ export function ChatPane({
{pendingToolApproval && (
<div className={styles.approval} data-testid="pane-approval">
<ToolApproval
showBrowserNotifications
disabled={isExitPlanApproval && modeControlsBusy}
planExecutionMode={connection.planExecutionMode}
request={pendingToolApproval}
@@ -11,6 +11,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import type { PermissionRequest } from '../../adapters/types';
import { AskUserQuestion } from './AskUserQuestion';
import { BrowserTurnNotifications } from '../../browser-turn-notifications';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -93,6 +94,7 @@ afterEach(() => {
container?.remove();
root = null;
container = null;
vi.unstubAllGlobals();
});
function rerender(
@@ -1483,3 +1485,104 @@ describe('AskUserQuestion multi-select', () => {
expect(submitButton()!.disabled).toBe(true);
});
});
function renderNotificationQuestion() {
vi.stubGlobal('isSecureContext', true);
vi.stubGlobal(
'Notification',
Object.assign(vi.fn(), { permission: 'granted' }),
);
render();
act(() =>
root!.render(
<I18nProvider language="en">
<BrowserTurnNotifications language="en">
<AskUserQuestion
request={request}
onConfirm={onConfirm}
onError={onError}
/>
</BrowserTurnNotifications>
</I18nProvider>,
),
);
const option = optionButtons()[0];
act(() => option.focus());
const bell = container!.querySelector<HTMLButtonElement>(
'[aria-label="Task notifications"]',
)!;
act(() =>
bell.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })),
);
return { bell, option };
}
it.each(['Escape', 'Control+Enter'])(
'isolates %s from the focused question under hover-opened notification help',
(key) => {
const { option } = renderNotificationQuestion();
expect(document.activeElement).toBe(option);
expect(
document.querySelector('[data-web-shell-notification-help]'),
).not.toBeNull();
pressKey(option, key === 'Escape' ? 'Escape' : 'Enter', {
cancelable: true,
ctrlKey: key === 'Control+Enter',
});
expect(onConfirm).not.toHaveBeenCalled();
if (key === 'Escape') {
expect(
document.querySelector('[data-web-shell-notification-help]'),
).toBeNull();
expect(document.activeElement).toBe(option);
} else {
expect(
document.querySelector('[data-web-shell-notification-help]'),
).not.toBeNull();
expect(document.activeElement).toBe(option);
}
},
);
it('preserves navigation and collapse controls under hover-opened notification help', () => {
const { bell, option } = renderNotificationQuestion();
pressKey(option, 'ArrowDown', { cancelable: true });
expect(document.activeElement).toBe(optionButtons()[1]);
const collapse = container!.querySelector<HTMLButtonElement>(
'[aria-label="Collapse"]',
)!;
act(() => collapse.focus());
act(() =>
bell.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })),
);
expect(
document.querySelector('[data-web-shell-notification-help]'),
).not.toBeNull();
for (const key of ['Enter', ' ']) {
expect(pressKey(collapse, key, { cancelable: true }).defaultPrevented).toBe(
false,
);
}
act(() => collapse.click());
expect(collapse.getAttribute('aria-expanded')).toBe('false');
expect(onConfirm).not.toHaveBeenCalled();
});
it('allows spaces in a custom answer while blocking modifier-Enter under hover-opened help', () => {
const { bell, option } = renderNotificationQuestion();
pressKey(option, 'Escape', { cancelable: true });
act(() => optionButtons().at(-1)!.click());
const input = container!.querySelector<HTMLInputElement>('input')!;
act(() => input.focus());
act(() =>
bell.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })),
);
expect(pressKey(input, ' ', { cancelable: true }).defaultPrevented).toBe(
false,
);
expect(
pressKey(input, 'Enter', { ctrlKey: true, cancelable: true })
.defaultPrevented,
).toBe(true);
expect(onConfirm).not.toHaveBeenCalled();
});
@@ -13,6 +13,7 @@ import { isEditableTarget } from '../../utils/dom';
import { Spinner } from '../ui/spinner';
import { localizeToolDisplayName } from './toolFormatting';
import styles from './AskUserQuestion.module.css';
import { BrowserNotificationControl } from './BrowserNotificationControl';
interface Question {
question: string;
@@ -476,6 +477,20 @@ export function AskUserQuestion({
// option navigation remains scoped to the roving-tabindex option group.
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (
e.currentTarget.querySelector('[data-web-shell-notification-open]') &&
((e.key === 'Enter' && (e.ctrlKey || e.metaKey)) ||
(!isEditableTarget(e.target) &&
e.target instanceof Element &&
e.target.closest('[data-web-shell-ask-option]') &&
((e.key >= '1' && e.key <= '9') ||
e.key === 'Enter' ||
e.key === ' ')))
) {
e.preventDefault();
return;
}
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
if (collapsed) {
if (
@@ -730,6 +745,7 @@ export function AskUserQuestion({
/>
</svg>
</button>
<BrowserNotificationControl />
</div>
</div>
@@ -0,0 +1,299 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { useBrowserNotificationSettings } from '../../browser-turn-notifications';
import { I18nProvider } from '../../i18n';
import { BrowserNotificationControl } from './BrowserNotificationControl';
import { ToolApproval } from './ToolApproval';
vi.mock('../../browser-turn-notifications', () => ({
useBrowserNotificationSettings: vi.fn(),
}));
const settings = {
enabled: true,
permission: 'granted' as NotificationPermission | 'unavailable',
pending: false,
persistent: true,
error: false,
setEnabled: vi.fn(async () => {}),
requestPermissionOnce: vi.fn(),
refreshPermission: vi.fn(),
syncLanguage: vi.fn(),
};
let root: Root;
let container: HTMLDivElement;
const selector = '[aria-label="Task notifications"]';
const popup = '[data-web-shell-notification-help]';
function render(node: ReactNode = <BrowserNotificationControl />) {
act(() => root.render(<I18nProvider language="en">{node}</I18nProvider>));
}
function trigger() {
return container.querySelector<HTMLButtonElement>(selector)!;
}
function hover() {
act(() =>
trigger().dispatchEvent(new MouseEvent('pointerover', { bubbles: true })),
);
}
function leave() {
act(() =>
trigger().dispatchEvent(
new MouseEvent('pointerout', {
bubbles: true,
relatedTarget: document.body,
}),
),
);
act(() => vi.advanceTimersByTime(250));
}
function escape(target: Element = trigger()) {
act(() =>
target.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
),
);
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.useFakeTimers();
Object.assign(settings, {
enabled: true,
permission: 'granted',
pending: false,
persistent: true,
error: false,
});
settings.setEnabled.mockReset().mockResolvedValue(undefined);
vi.mocked(useBrowserNotificationSettings).mockReturnValue(settings);
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('describes a help popup rather than a settings toggle', () => {
render();
expect(trigger().hasAttribute('aria-pressed')).toBe(false);
expect(trigger().getAttribute('aria-haspopup')).toBe('dialog');
hover();
expect(trigger().getAttribute('aria-expanded')).toBe('true');
});
it('restores focus on Escape and still closes a re-hovered popup on pointer leave', () => {
render();
hover();
const help = document.querySelector<HTMLElement>(popup)!;
act(() => help.focus());
escape(help);
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger());
hover();
expect(document.querySelector(popup)).not.toBeNull();
leave();
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger());
});
it('keeps keyboard-focused trigger and content open across the close timer', () => {
render();
act(() => trigger().focus());
leave();
expect(document.querySelector(popup)).not.toBeNull();
const help = document.querySelector<HTMLElement>(popup)!;
act(() => help.focus());
leave();
expect(document.querySelector(popup)).not.toBeNull();
});
it('closes on pointer leave when neither trigger nor content has focus', () => {
render();
hover();
leave();
expect(document.querySelector(popup)).toBeNull();
});
it('keeps enabled status and separately reports a delivery error', () => {
settings.error = true;
render();
hover();
const help = document.querySelector(popup)!;
expect(help.querySelector('[role="status"]')?.textContent).toBe(
'Enabled for this site.',
);
expect(help.textContent).toContain('Unable to enable or show notifications.');
});
it('explains when the preference cannot persist', () => {
settings.persistent = false;
render();
hover();
expect(document.querySelector(popup)?.textContent).toContain(
'This setting is saved for the current page only.',
);
});
it('keeps authorization help open through pending blur and restores focus after grant', async () => {
settings.enabled = false;
settings.permission = 'default';
vi.mocked(useBrowserNotificationSettings).mockImplementation(() => ({
...settings,
}));
let grant!: () => void;
settings.setEnabled.mockImplementation(async () => {
settings.pending = true;
render();
await new Promise<void>((resolve) => {
grant = resolve;
});
settings.pending = false;
settings.enabled = true;
settings.permission = 'granted';
render();
});
render();
hover();
const allow = document.querySelector<HTMLButtonElement>(`${popup} button`)!;
act(() => allow.focus());
act(() => allow.click());
expect(allow.disabled).toBe(true);
act(() => allow.blur());
act(() =>
document.querySelector(popup)!.dispatchEvent(
new MouseEvent('pointerout', {
bubbles: true,
relatedTarget: document.body,
}),
),
);
act(() => vi.advanceTimersByTime(250));
expect(document.querySelector(popup)).not.toBeNull();
await act(async () => grant());
expect(document.activeElement).toBe(trigger());
expect(document.querySelector('[role="status"]')?.textContent).toBe(
'Enabled for this site.',
);
});
it.each(['Enter', ' ', 'ArrowDown'])(
'opens help with %s and focuses the portaled content',
(key) => {
render();
act(() => trigger().focus());
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
act(() => trigger().dispatchEvent(event));
act(() => vi.advanceTimersByTime(20));
expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(document.querySelector(popup));
},
);
it('preserves global shortcuts from the trigger with help open and closed', () => {
render();
act(() => trigger().focus());
const listener = vi.fn();
window.addEventListener('keydown', listener);
try {
for (const open of [true, false]) {
if (!open) escape();
listener.mockClear();
act(() =>
trigger().dispatchEvent(
new KeyboardEvent('keydown', {
key: 'b',
ctrlKey: true,
bubbles: true,
}),
),
);
expect(listener).toHaveBeenCalledOnce();
}
} finally {
window.removeEventListener('keydown', listener);
}
});
it('keeps unrelated input focus when Escape closes hover-opened help', () => {
render();
const input = document.createElement('textarea');
container.append(input);
act(() => input.focus());
hover();
escape(input);
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(input);
});
it.each(['data-plan-interactive', 'data-approval-shortcuts-ignore'])(
'preserves native activation of %s controls while help is hover-open',
(attribute) => {
render(
<ToolApproval
request={{
id: 'diff',
content: [
{ type: 'diff', path: 'test.ts', oldText: 'a', newText: 'b' },
],
options: [{ id: 'cancel', label: 'Reject', kind: 'reject_once' }],
}}
onConfirm={vi.fn()}
showBrowserNotifications
/>,
);
const target = container.querySelector<HTMLElement>(
'[data-plan-interactive]',
)!;
target.removeAttribute('data-plan-interactive');
target.setAttribute(attribute, '');
target.tabIndex = 0;
act(() => target.focus());
hover();
for (const key of ['Enter', ' ']) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
act(() => target.dispatchEvent(event));
expect(event.defaultPrevented).toBe(false);
}
},
);
it('exposes the control only on daemon-backed approval cards and preserves a second Escape', () => {
const onConfirm = vi.fn();
const request = {
id: 'request',
content: [],
options: [{ id: 'cancel', label: 'Reject', kind: 'reject_once' as const }],
};
render(<ToolApproval request={request} onConfirm={onConfirm} />);
expect(trigger()).toBeNull();
render(
<ToolApproval
request={request}
onConfirm={onConfirm}
showBrowserNotifications
/>,
);
act(() => trigger().focus());
escape();
expect(onConfirm).not.toHaveBeenCalled();
expect(document.querySelector(popup)).toBeNull();
escape();
expect(onConfirm).toHaveBeenCalledWith('request', 'cancel');
});
@@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from 'react';
import {
Bell,
BellOff,
BellRing,
ChevronDownIcon,
CircleCheckIcon,
} from 'lucide-react';
import { useBrowserNotificationSettings } from '../../browser-turn-notifications';
import { useI18n } from '../../i18n';
import { getShadowAwareActiveElement } from '../../utils/dom';
import { Button } from '../ui/button';
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '../ui/popover';
export function BrowserNotificationControl() {
const settings = useBrowserNotificationSettings();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const content = useRef<HTMLDivElement>(null);
const restoredFocus = useRef(false);
const pending = useRef(settings?.pending);
pending.current = settings?.pending;
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestPermissionOnce = settings?.requestPermissionOnce;
useEffect(() => requestPermissionOnce?.(), [requestPermissionOnce]);
useEffect(() => () => clearTimeout(timer.current), []);
if (!settings) return null;
const enabled = settings.enabled && settings.permission === 'granted';
const canAuthorize =
settings.permission === 'default' || settings.permission === 'granted';
const status = settings.pending
? 'requesting'
: settings.permission === 'unavailable'
? 'unavailable'
: settings.permission === 'denied'
? 'denied'
: enabled
? 'helpEnabled'
: settings.error
? 'error'
: settings.permission === 'default'
? 'helpWaiting'
: 'disabled';
const Icon = enabled
? BellRing
: settings.permission === 'default'
? Bell
: BellOff;
const cancelClose = () => clearTimeout(timer.current);
const show = () => {
cancelClose();
settings.refreshPermission();
setOpen(true);
};
const leave = () => {
cancelClose();
timer.current = setTimeout(() => {
if (pending.current) return;
if (
(restoredFocus.current || !trigger.current?.matches(':focus-within')) &&
!content.current?.matches(':focus-within')
)
setOpen(false);
}, 200);
};
return (
<span
className="ml-auto inline-flex shrink-0"
data-web-shell-notification-open={open || undefined}
>
<Popover
open={open}
onOpenChange={(next) => {
cancelClose();
setOpen(next);
}}
>
<PopoverTrigger asChild>
<Button
ref={trigger}
type="button"
variant="ghost"
size="icon-xs"
className={enabled ? 'text-primary' : 'text-muted-foreground'}
aria-label={t('browserNotifications.label')}
title={t(`browserNotifications.${status}`)}
onPointerEnter={(event) => {
if (event.pointerType !== 'touch') show();
}}
onPointerLeave={leave}
onFocus={() => {
restoredFocus.current = false;
show();
}}
onBlur={leave}
onClick={(event) => {
event.preventDefault();
show();
}}
onKeyDown={(event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (!['Enter', ' ', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
restoredFocus.current = false;
show();
requestAnimationFrame(() =>
content.current?.focus({ preventScroll: true }),
);
}}
>
<Icon aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent
ref={content}
data-web-shell-notification-help
align="end"
side="top"
collisionPadding={12}
className="max-h-[min(70vh,520px,var(--radix-popover-content-available-height))] w-[360px] max-w-[calc(100vw-24px)] gap-3 overflow-auto p-4 text-xs leading-5"
aria-label={t('browserNotifications.label')}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
event.stopPropagation();
if (
content.current?.matches(':focus-within') ||
trigger.current?.matches(':focus-within')
) {
trigger.current?.focus({ preventScroll: true });
restoredFocus.current = true;
}
}}
onPointerEnter={cancelClose}
onPointerLeave={leave}
onFocusCapture={cancelClose}
onBlurCapture={leave}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<PopoverTitle className="text-base font-semibold">
{t('browserNotifications.label')}
</PopoverTitle>
<div role="status">
{status === 'helpEnabled' ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="w-full disabled:opacity-100"
>
<CircleCheckIcon
aria-hidden="true"
className="text-[var(--success-color)]"
/>
{t('browserNotifications.helpEnabled')}
</Button>
) : (
<p>{t(`browserNotifications.${status}`)}</p>
)}
</div>
{!enabled && (
<Button
type="button"
size="sm"
disabled={!canAuthorize || settings.pending}
onClick={async (event) => {
const button = event.currentTarget;
await settings.setEnabled(true);
const active = getShadowAwareActiveElement(trigger.current);
if (
!active ||
active === button ||
active === trigger.current?.ownerDocument.body
)
trigger.current?.focus({ preventScroll: true });
}}
>
{t(
settings.permission === 'granted'
? 'browserNotifications.enable'
: 'browserNotifications.allow',
)}
</Button>
)}
{enabled && settings.error && (
<p className="text-muted-foreground">
{t('browserNotifications.error')}
</p>
)}
{!settings.persistent && (
<p className="text-muted-foreground">
{t('browserNotifications.temporary')}
</p>
)}
<p>{t('browserNotifications.helpWhen')}</p>
<p className="text-muted-foreground">
{t('browserNotifications.helpSystem')}
</p>
<details className="group shrink-0">
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium [&::-webkit-details-marker]:hidden">
macOS
<ChevronDownIcon
aria-hidden="true"
className="size-3.5 transition-transform group-open:rotate-180"
/>
</summary>
<p className="mt-1 text-muted-foreground">
{t('browserNotifications.helpMac')}
</p>
</details>
<details className="group shrink-0">
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium [&::-webkit-details-marker]:hidden">
Windows
<ChevronDownIcon
aria-hidden="true"
className="size-3.5 transition-transform group-open:rotate-180"
/>
</summary>
<p className="mt-1 text-muted-foreground">
{t('browserNotifications.helpWindows')}
</p>
</details>
</PopoverContent>
</Popover>
</span>
);
}
@@ -918,10 +918,11 @@ describe('SettingsMessage user-scope editing', () => {
setEnabled: vi.fn(async () => {}),
refreshPermission: vi.fn(),
syncLanguage: vi.fn(),
requestPermissionOnce: vi.fn(),
};
const state = makeState([themeSetting()], vi.fn());
const baseline = renderPanel(state);
expect(baseline.textContent).toContain('Browser task notifications');
expect(baseline.textContent).toContain('Task notifications');
const container = renderPanel(state, {
presentation: { includeItems: ['builtin:chat-width'] },
});
@@ -930,7 +931,7 @@ describe('SettingsMessage user-scope editing', () => {
expect(container.querySelectorAll('nav button')).toHaveLength(1);
expect(container.textContent).toContain('Chat width');
expect(container.textContent).not.toContain('Theme');
expect(container.textContent).not.toContain('Browser task notifications');
expect(container.textContent).not.toContain('Task notifications');
}
});
@@ -1045,13 +1046,8 @@ describe('SettingsMessage user-scope editing', () => {
});
it.each([
['builtin:chat-width', 'UI', 'Chat width', 'Browser task notifications'],
[
'builtin:browser-notifications',
'UI',
'Browser task notifications',
'Chat width',
],
['builtin:chat-width', 'UI', 'Chat width', 'Task notifications'],
['builtin:browser-notifications', 'UI', 'Task notifications', 'Chat width'],
['builtin:live-setup', 'Experimental', 'Qwen Live', undefined],
['builtin:local-control', 'Daemon', 'Local Control', undefined],
['builtin:model-management', 'Model', 'model-management', undefined],
@@ -1067,6 +1063,7 @@ describe('SettingsMessage user-scope editing', () => {
setEnabled: vi.fn(async () => {}),
refreshPermission: vi.fn(),
syncLanguage: vi.fn(),
requestPermissionOnce: vi.fn(),
};
const container = renderPanel(makeState([], vi.fn(), liveSetup(false)), {
modelManagementSectionProps: makeModelManagement(),
@@ -28,12 +28,14 @@ import styles from './ToolApproval.module.css';
import { buildUnifiedDiff } from '../../utils/unifiedDiff';
import { DiffView } from './tools/DiffView';
import { useWebShellCustomization } from '../../customization';
import { BrowserNotificationControl } from './BrowserNotificationControl';
interface ToolApprovalProps {
request: PermissionRequest;
onConfirm: (id: string, selectedOption: string) => void | Promise<void>;
variant?: 'inline' | 'floating';
disabled?: boolean;
showBrowserNotifications?: boolean;
/**
* Whether this approval should pull keyboard focus to its safe-default option
* when it becomes the topmost (visible) one — on appearance, or when a panel/
@@ -255,6 +257,7 @@ export function ToolApproval({
onConfirm,
variant = 'inline',
disabled = false,
showBrowserNotifications = false,
keyboardActive = true,
extraDescriptionId,
planTodos = [],
@@ -496,6 +499,18 @@ export function ToolApproval({
) {
return;
}
if (
e.currentTarget.querySelector('[data-web-shell-notification-open]') &&
!isEditableTarget(e.target) &&
((e.key >= '1' && e.key <= '9') ||
((e.key === 'Enter' || e.key === ' ') &&
e.target instanceof Element &&
e.target.closest('[data-web-shell-permission-option]')))
) {
e.preventDefault();
return;
}
const count = displayOptions.length;
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
@@ -643,6 +658,7 @@ export function ToolApproval({
<span className={styles.name} id={headingId}>
{toolName}
</span>
{showBrowserNotifications && <BrowserNotificationControl />}
</div>
{descriptionText && (
@@ -1,6 +1,9 @@
import type { ACPToolCall } from '../../adapters/types';
import type { ACPToolCall } from '../../adapters/types.js';
import { isAskUserQuestionToolName } from '../../constants/toolNames.js';
export { isActiveToolStatus } from '../../adapters/toolClassification';
export { isAskUserQuestionToolName } from '../../constants/toolNames.js';
export { isActiveToolStatus } from '../../adapters/toolClassification.js';
/**
* Internal-tool-name → display-name lookup. This is a standalone copy of
@@ -158,11 +161,6 @@ export function localizeToolDisplayName(
return displayName;
}
export function isAskUserQuestionToolName(toolName: string): boolean {
const normalized = toolName.toLowerCase();
return normalized === 'ask_user_question' || normalized === 'askuserquestion';
}
export function isAdvisorToolName(toolName: string): boolean {
return toolName.toLowerCase() === 'advisor';
}
@@ -11,3 +11,8 @@ export const AGENT_TOOL_NAME = 'agent';
export function isAgentTool(toolName: string | undefined): boolean {
return toolName === AGENT_TOOL_NAME;
}
export function isAskUserQuestionToolName(toolName: string): boolean {
const normalized = toolName.toLowerCase();
return normalized === 'ask_user_question' || normalized === 'askuserquestion';
}
@@ -23694,6 +23694,95 @@ describe('DaemonSessionProvider', () => {
},
);
it.each(['approval', 'question', 'no observer'] as const)(
'flushes pending action projections only for a notification observer (%s)',
async (kind) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 204 })),
);
const gate = createDeferred<void>();
const consumed = createDeferred<void>();
const notify = vi.fn();
const session = createMockSession({
sessionId: 'action-projection',
async *events(opts) {
yield {
v: 1,
type: 'replay_complete',
data: { sessionId: 'action-projection' },
};
await gate.promise;
yield {
v: 1,
type: 'permission_request',
data: {
sessionId: 'action-projection',
requestId: 'pending-action',
toolCall:
kind === 'question'
? {
_meta: { toolName: 'ask_user_question' },
rawInput: { questions: [{ question: 'Choose?' }] },
}
: { name: 'Bash', input: { command: 'echo hello' } },
options: [
{ optionId: 'allow_once', label: 'Allow', kind: 'allow_once' },
],
},
};
consumed.resolve();
yield* createIdleEvents()(opts);
},
});
sdkMocks.sessions.push(session);
let store: DaemonTranscriptStore | undefined;
function Harness() {
store = useDaemonTranscriptStore();
return null;
}
await renderWithProvider(
<Harness />,
{ autoConnect: true },
kind === 'no observer'
? undefined
: createTurnNotificationObserver(notify),
);
vi.useFakeTimers();
try {
await act(async () => {
gate.resolve();
await consumed.promise;
await flushPromises();
});
if (kind === 'no observer') {
expect(
store!
.getSnapshot()
.blocks.some((block) => block.kind === 'permission'),
).toBe(false);
act(() => vi.advanceTimersByTime(16));
expect(
store!
.getSnapshot()
.blocks.some((block) => block.kind === 'permission'),
).toBe(true);
} else {
expect(notify).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ outcome: kind }),
);
expect(
store!
.getSnapshot()
.blocks.some((block) => block.kind === 'permission'),
).toBe(true);
}
} finally {
vi.useRealTimers();
}
},
);
it('toggles browser notifications without restarting the session stream', async () => {
vi.stubGlobal('isSecureContext', true);
vi.stubGlobal('crypto', webcrypto);
@@ -3924,15 +3924,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
);
}
if (sessionRef.current === activeSession) {
turnNotifications.observe(activeSession, event, false, () =>
getTurnNotificationContent(
turnNotifications.observe(activeSession, event, false, () => {
if (event.type === 'permission_request')
flushTranscriptSync();
return getTurnNotificationContent(
event,
store.getSnapshot().blocks,
connectionRef.current.sessionId === activeSession.sessionId
? connectionRef.current.displayName
: getSessionDisplayName(activeSession.state),
),
);
);
});
}
if (
event.type === 'turn_complete' ||
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type {
DaemonEvent,
DaemonPermissionTranscriptBlock,
DaemonTextTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import {
@@ -15,7 +16,84 @@ function terminal(promptId = 'p', stopReason = 'end_turn'): DaemonEvent {
};
}
function permission(requestId = 'p'): DaemonEvent {
return {
v: 1,
type: 'permission_request',
data: { sessionId: 's', requestId },
};
}
describe('turn notification observer', () => {
it.each(['approval', 'question'] as const)(
'notifies once per %s request without consuming its turn',
(attention) => {
const notify = vi.fn();
const observer = createTurnNotificationObserver(notify);
observer.retain('scope');
observer.retain('scope');
observer.admit('scope', 'p', 'Original prompt');
const content = vi.fn(() => ({ attention, sessionTitle: 'Session' }));
observer.observe('scope', 's', permission(), false, content);
observer.observe('scope', 's', permission(), false, content);
expect(content).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledExactlyOnceWith({
key: JSON.stringify(['scope', 'permission', 'p']),
sessionTitle: 'Session',
outcome: attention,
});
observer.observe('scope', 's', permission('second'), false, content);
observer.observe('scope', 's', terminal(), true);
expect(notify).toHaveBeenCalledTimes(3);
expect(notify).toHaveBeenLastCalledWith(
expect.objectContaining({
outcome: 'completed',
promptText: 'Original prompt',
}),
);
},
);
it('ignores replay, invalid identities, and requests without an unresolved projection', () => {
const notify = vi.fn();
const observer = createTurnNotificationObserver(notify);
observer.retain('scope');
const content = vi.fn(() => ({ attention: 'approval' as const }));
observer.observe('scope', 's', permission(), true, content);
observer.observe('scope', 'other', permission(), false, content);
observer.observe(
'scope',
's',
{ ...permission(), sessionId: 'other' } as DaemonEvent & {
sessionId: string;
},
false,
content,
);
observer.observe('scope', 's', permission(' '), false, content);
observer.observe('unmounted', 's', permission(), false, content);
observer.observe('scope', 's', permission(), false, () => undefined);
expect(content).not.toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
observer.observe('scope', 's', permission(), false, content);
expect(notify).toHaveBeenCalledOnce();
});
it('isolates failing action notifications and scopes the request cache', () => {
const notify = vi.fn(() => {
throw new Error('unavailable');
});
const observer = createTurnNotificationObserver(notify);
observer.retain('scope');
observer.retain('other');
const content = { attention: 'question' as const };
expect(() =>
observer.observe('scope', 's', permission(), false, content),
).not.toThrow();
observer.observe('scope', 's', permission(), false, content);
observer.observe('other', 's', permission(), false, content);
expect(notify).toHaveBeenCalledTimes(2);
});
it.each([
['end_turn', 'completed'],
['cancelled', 'cancelled'],
@@ -232,6 +310,70 @@ describe('turn notification observer', () => {
});
describe('turn notification content', () => {
const approvalBlock = (
toolCall: unknown,
resolved?: string,
): DaemonPermissionTranscriptBlock => ({
id: 'permission-p',
kind: 'permission',
requestId: 'p',
sessionId: 's',
title: 'Sensitive command or question',
options: [],
preview: { kind: 'generic' },
toolCall,
resolved,
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
});
it.each([
[{ name: 'Bash', input: { command: 'private command' } }, 'approval'],
[
{
_meta: { toolName: 'AskUserQuestion' },
rawInput: { questions: [{ question: 'Private question?' }] },
},
'question',
],
[{ input: { questions: [{ question: 'Legacy question?' }] } }, 'question'],
[
{ _meta: { toolName: 'other_tool' }, args: { questions: [] } },
'approval',
],
] as const)(
'classifies the same pending request as the UI without copying its details (%s)',
(toolCall, attention) => {
const content = getTurnNotificationContent(
permission(),
[approvalBlock(toolCall)],
'Session title',
);
expect(content).toEqual({ sessionTitle: 'Session title', attention });
},
);
it('requires the matching unresolved permission block', () => {
expect(
getTurnNotificationContent(
permission('other'),
[approvalBlock({})],
'Session',
),
).toBeUndefined();
expect(
getTurnNotificationContent(
permission(),
[approvalBlock({}, 'allowed')],
'Session',
),
).toBeUndefined();
expect(
getTurnNotificationContent(permission(), [], 'Session'),
).toBeUndefined();
});
const block = (
text: string,
extra: Partial<DaemonTextTranscriptBlock> = {},
@@ -14,6 +14,8 @@ import {
assistantBlockRendersAsSystemNotice,
splitInsightSegments,
} from '../../adapters/transcriptToMessages.js';
import { extractPendingPermission } from '../../adapters/transcriptAdapter.js';
import { isAskUserPermission } from '../../utils/askUserPermission.js';
import type {
DaemonConnectionState,
DaemonProductSessionContext,
@@ -33,11 +35,18 @@ export interface TurnNotificationContent {
sessionTitle?: string;
promptText?: string;
responseText?: string;
attention?: 'approval' | 'question';
}
export interface TurnNotification extends TurnNotificationContent {
key: string;
outcome: 'completed' | 'failed' | 'ended' | 'cancelled';
outcome:
| 'completed'
| 'failed'
| 'ended'
| 'cancelled'
| 'approval'
| 'question';
}
export interface TurnNotificationObserver {
@@ -219,6 +228,20 @@ export function getTurnNotificationContent(
blocks: readonly DaemonTranscriptBlock[],
sessionTitle: string | undefined,
): TurnNotificationContent | undefined {
if (event.type === 'permission_request') {
const requestId = (event.data as { requestId?: unknown } | undefined)
?.requestId;
const request = extractPendingPermission(
blocks.filter(
(block) => block.kind === 'permission' && block.requestId === requestId,
),
);
if (!request) return;
return {
sessionTitle,
attention: isAskUserPermission(request) ? 'question' : 'approval',
};
}
if (event.type !== 'turn_complete' && event.type !== 'turn_error') return;
const content: TurnNotificationContent = { sessionTitle };
const promptId = (event.data as { promptId?: unknown } | undefined)?.promptId;
@@ -280,15 +303,17 @@ export function createTurnNotificationObserver(
const handled = new Set<string>();
const keyFor = (scope: string, promptId: string) =>
JSON.stringify([scope, promptId]);
const consume = (scope: string, promptId: string) => {
const key = keyFor(scope, promptId);
scopes.get(scope)?.pending.delete(promptId);
const claim = (key: string) => {
if (handled.has(key)) return false;
handled.add(key);
if (handled.size > MAX_RECENT_TURNS)
handled.delete(handled.values().next().value!);
return true;
};
const consume = (scope: string, promptId: string) => {
scopes.get(scope)?.pending.delete(promptId);
return claim(keyFor(scope, promptId));
};
return {
retain(scope) {
let state = scopes.get(scope);
@@ -339,6 +364,23 @@ export function createTurnNotificationObserver(
(envelopeSessionId !== undefined && envelopeSessionId !== sessionId)
)
return;
if (event.type === 'permission_request') {
const requestId = value['requestId'];
if (replay || typeof requestId !== 'string' || !requestId.trim())
return;
const key = JSON.stringify([scope, 'permission', requestId]);
if (handled.has(key)) return;
try {
const resolvedContent =
typeof content === 'function' ? content() : content;
if (!resolvedContent?.attention) return;
const { attention, ...details } = resolvedContent;
if (claim(key)) notify({ ...details, key, outcome: attention });
} catch {
// Notification observers must never interrupt session event handling.
}
return;
}
if (
event.type === 'mid_turn_message_injected' &&
Array.isArray(value['messageIds'])
@@ -701,7 +701,7 @@ export function replayCompleteEvent(
export function permissionRequestEvent(
requestId: string,
options: { id?: number; sessionId?: string } = {},
options: { id?: number; sessionId?: string; toolCall?: unknown } = {},
): DaemonEvent {
return {
...(options.id !== undefined ? { id: options.id } : {}),
@@ -710,15 +710,15 @@ export function permissionRequestEvent(
data: {
requestId,
sessionId: options.sessionId,
toolCall: {
toolCall: options.toolCall ?? {
name: 'Bash',
input: {
command: 'printf web-shell-e2e',
},
},
options: [
{ optionId: 'allow_once', label: 'Allow once' },
{ optionId: 'reject_once', label: 'Reject' },
{ optionId: 'allow_once', label: 'Allow', kind: 'allow_once' },
{ optionId: 'cancel', label: 'Reject', kind: 'reject_once' },
],
},
};
@@ -0,0 +1,715 @@
import { expect, test, type Page } from '@playwright/test';
import type { DaemonPermissionRequestEvent } from '@qwen-code/sdk/daemon';
import {
createWebShellDaemonScenario,
installMockDaemon,
permissionRequestEvent,
turnCompleteEvent,
} from './utils/mockDaemon';
interface CapturedNotification {
title: string;
options: NotificationOptions;
onclick?: () => void;
close(): void;
}
declare global {
interface Window {
__taskNotifications: CapturedNotification[];
__taskNotificationFocused: boolean;
__taskNotificationPermissionRequests: number;
__taskNextNotificationPermission: NotificationPermission;
__taskHoldNotificationPermission: boolean;
__taskResolveNotificationPermission?: (
permission: NotificationPermission,
) => void;
}
}
interface NotificationCaptureOptions {
enabled?: boolean;
permission?: NotificationPermission | 'unavailable';
savedPreference?: boolean;
}
async function captureNotifications(
page: Page,
options: boolean | NotificationCaptureOptions = true,
) {
const {
enabled = true,
permission = 'granted',
savedPreference = true,
} = typeof options === 'boolean' ? { enabled: options } : options;
await page.addInitScript(
({ enabled, permission, savedPreference }) => {
if (!sessionStorage.getItem('notification-test-initialized')) {
const preferenceKey = 'qwen-code-web-shell-browser-notifications';
if (savedPreference)
localStorage.setItem(preferenceKey, String(enabled));
else localStorage.removeItem(preferenceKey);
sessionStorage.setItem('notification-test-initialized', 'true');
}
window.__taskNotifications = [];
window.__taskNotificationFocused = false;
window.__taskNotificationPermissionRequests = Number(
sessionStorage.getItem('notification-test-permission-requests') ?? 0,
);
window.__taskNextNotificationPermission = 'default';
window.__taskHoldNotificationPermission = false;
Object.defineProperty(document, 'hasFocus', {
value: () => window.__taskNotificationFocused,
});
Object.defineProperty(document, 'visibilityState', {
get: () => 'visible',
});
Object.defineProperty(window, 'Notification', {
value:
permission === 'unavailable'
? undefined
: class {
static permission =
sessionStorage.getItem('notification-test-permission') ??
permission;
static async requestPermission() {
window.__taskNotificationPermissionRequests++;
sessionStorage.setItem(
'notification-test-permission-requests',
String(window.__taskNotificationPermissionRequests),
);
this.permission = window.__taskHoldNotificationPermission
? await new Promise<NotificationPermission>((resolve) => {
window.__taskResolveNotificationPermission = resolve;
})
: window.__taskNextNotificationPermission;
sessionStorage.setItem(
'notification-test-permission',
this.permission,
);
return this.permission;
}
onclick?: () => void;
constructor(
public title: string,
public options: NotificationOptions,
) {
window.__taskNotifications.push(this);
}
close() {}
},
});
},
{ enabled, permission, savedPreference },
);
}
function actionRequest(
sessionId: string,
kind: 'approval' | 'question',
id = 1,
): DaemonPermissionRequestEvent {
const requestId = `action-${id}`;
return permissionRequestEvent(requestId, {
id,
sessionId,
toolCall:
kind === 'question'
? {
toolCallId: requestId,
title: 'Ask user 1 question',
kind: 'think',
_meta: { toolName: 'ask_user_question' },
rawInput: {
questions: [
{
header: 'Destination',
question: 'Which private deployment target?',
options: [{ label: 'Staging' }, { label: 'Production' }],
},
],
},
}
: undefined,
}) as DaemonPermissionRequestEvent;
}
for (const kind of ['approval', 'question'] as const) {
test(`notifies for a live ${kind} without approving it or consuming completion @smoke`, async ({
page,
}, testInfo) => {
await captureNotifications(page);
const scenario = createWebShellDaemonScenario({ events: [] });
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(page.getByRole('banner')).toContainText(scenario.displayName);
const event = actionRequest(scenario.sessionId, kind);
await daemon.sendEvent(event);
if (kind === 'question') {
await expect(
page.getByText('Which private deployment target?', { exact: true }),
).toBeVisible();
} else {
await expect(
page.getByText('Yes, allow once', { exact: true }),
).toBeVisible();
}
await expect
.poll(() => page.evaluate(() => window.__taskNotifications.length))
.toBe(1);
const notification = await page.evaluate(() => {
const { title, options } = window.__taskNotifications[0];
return { title, options };
});
await page.screenshot({
path: `../../.qwen/e2e-tests/browser-action-notifications/${kind}.png`,
fullPage: true,
});
expect(notification.title).toBe(`QwenCode · ${scenario.displayName}`);
expect(notification.options.body).toBe(
kind === 'approval'
? 'This session needs your approval. Return to review the request.'
: 'This session is waiting for your answer. Return to respond.',
);
expect(JSON.stringify(notification)).not.toMatch(
/printf|private deployment|Staging/,
);
await daemon.sendEvent({ ...event, id: 2 });
await page.evaluate(() => window.__taskNotifications[0].onclick?.());
expect(daemon.permissionRequests()).toHaveLength(0);
await daemon.sendEvent(
turnCompleteEvent('finished-action-turn', {
sessionId: scenario.sessionId,
id: 3,
}),
);
await expect
.poll(() => page.evaluate(() => window.__taskNotifications.length))
.toBe(2);
expect(
await page.evaluate(() => window.__taskNotifications[1].options.body),
).toBe('This turn has completed.');
});
}
test('keeps history and foreground requests silent and allows a later background request', async ({
page,
}, testInfo) => {
await captureNotifications(page);
const scenario = createWebShellDaemonScenario({ events: [] });
scenario.events = [actionRequest(scenario.sessionId, 'approval')];
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(page.getByRole('banner')).toContainText(scenario.displayName);
await expect(
page.getByRole('radio', { name: 'Yes, allow once', exact: true }),
).toBeVisible();
expect(await page.evaluate(() => window.__taskNotifications.length)).toBe(0);
await daemon.sendEvent({
id: 2,
v: 1,
type: 'permission_resolved',
data: {
requestId: 'action-1',
outcome: { outcome: 'selected', optionId: 'allow_once' },
},
});
await expect(page.getByRole('alertdialog')).not.toBeVisible();
await page.evaluate(() => {
window.__taskNotificationFocused = true;
});
await daemon.sendEvent(actionRequest(scenario.sessionId, 'question', 3));
await expect(
page.getByText('Which private deployment target?', { exact: true }),
).toBeVisible();
expect(await page.evaluate(() => window.__taskNotifications.length)).toBe(0);
await page.evaluate(() => {
window.__taskNotificationFocused = false;
});
await daemon.sendEvent(actionRequest(scenario.sessionId, 'approval', 4));
await expect
.poll(() => page.evaluate(() => window.__taskNotifications.length))
.toBe(1);
expect(
await page.evaluate(() => window.__taskNotifications[0].options.body),
).toContain('needs your approval');
});
test('does not notify when the existing browser notification setting is off', async ({
page,
}, testInfo) => {
await captureNotifications(page, false);
const scenario = createWebShellDaemonScenario({ events: [] });
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(page.getByRole('banner')).toContainText(scenario.displayName);
await daemon.sendEvent(actionRequest(scenario.sessionId, 'question'));
await expect(
page.getByText('Which private deployment target?', { exact: true }),
).toBeVisible();
expect(await page.evaluate(() => window.__taskNotifications.length)).toBe(0);
});
async function waitForAnimations(page: Page) {
await page.evaluate(async () => {
await Promise.all(
document
.getAnimations()
.filter(
(animation) =>
animation.effect?.getComputedTiming().iterations !== Infinity,
)
.map((animation) => animation.finished.catch(() => {})),
);
});
}
async function openActionPanel(
page: Page,
baseURL: string,
kind: 'approval' | 'question',
language = 'en',
) {
const scenario = createWebShellDaemonScenario({ events: [] });
const daemon = await installMockDaemon(page, scenario, { baseURL });
await page.goto(`/session/${scenario.sessionId}?language=${language}`);
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(page.getByRole('banner')).toContainText(scenario.displayName);
await daemon.sendEvent(actionRequest(scenario.sessionId, kind));
await expect(
kind === 'question'
? page.getByText('Which private deployment target?', { exact: true })
: page.getByText('Yes, allow once', { exact: true }),
).toBeVisible();
await waitForAnimations(page);
return { scenario, daemon };
}
for (const kind of ['approval', 'question'] as const) {
test(`exposes interactive browser notification help in the ${kind} header @smoke`, async ({
page,
}, testInfo) => {
await captureNotifications(page);
const { daemon } = await openActionPanel(
page,
String(testInfo.project.use.baseURL),
kind,
);
const button = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
const help = page.locator('[data-web-shell-notification-help]');
await expect(button).not.toHaveAttribute('aria-pressed');
await button.hover();
await expect(help).toBeVisible();
await help.hover();
await expect(help).toBeVisible();
await expect(help).toContainText('Enabled for this site.');
await expect(help.locator('[data-slot="popover-title"]')).toHaveCSS(
'font-size',
'16px',
);
const enabledStatus = help.getByRole('button', {
name: 'Enabled for this site.',
exact: true,
});
await expect(enabledStatus).toBeDisabled();
const successIcon = enabledStatus.locator('svg');
await expect(successIcon).toBeVisible();
const iconColors = await successIcon.evaluate((icon) => {
const reference = document.createElement('span');
reference.style.color = 'var(--success-color)';
icon.parentElement!.append(reference);
const expected = getComputedStyle(reference).color;
reference.remove();
return { actual: getComputedStyle(icon).color, expected };
});
expect(iconColors.actual).toBe(iconColors.expected);
await expect(help).toContainText(/background|unfocused/);
await expect(help).toContainText(
'If no reminders appear, follow the steps below to check system notifications.',
);
await waitForAnimations(page);
await expect
.poll(() =>
help.evaluate((node) => {
const bounds = node.getBoundingClientRect();
return bounds.top >= 0 && bounds.bottom <= window.innerHeight;
}),
)
.toBe(true);
await expect(help).toContainText(/macOS/);
await expect(help).toContainText(/Windows/);
for (const system of ['macOS', 'Windows']) {
const guide = help.locator('details').filter({ hasText: system });
const summary = guide.locator('summary');
await expect(guide).not.toHaveAttribute('open');
await expect(guide.locator('p')).not.toBeVisible();
await expect(summary.locator('svg')).toBeVisible();
await summary.click();
await expect(guide).toHaveAttribute('open', '');
await expect(guide.locator('p')).toBeVisible();
await summary.press('Enter');
await expect(guide).not.toHaveAttribute('open');
await expect(guide.locator('p')).not.toBeVisible();
expect(daemon.permissionRequests()).toHaveLength(0);
}
await page.screenshot({
path: `../../.qwen/e2e-tests/browser-action-notifications/${kind}-popover.png`,
fullPage: true,
});
await help.focus();
for (const key of ['1', 'Control+Enter']) {
await help.press(key);
expect(daemon.permissionRequests()).toHaveLength(0);
await button.focus();
await expect(help).toBeVisible();
await help.focus();
}
await help.press('Escape');
await expect(help).not.toBeVisible();
await expect(button).toBeFocused();
expect(daemon.permissionRequests()).toHaveLength(0);
await button.press('ArrowDown');
await expect(help).toBeVisible();
await expect(help).toBeFocused();
await help.press('Escape');
await expect(help).not.toBeVisible();
await expect(button).toBeFocused();
await button.click();
await expect(help).toBeVisible();
expect(daemon.permissionRequests()).toHaveLength(0);
await expect(
kind === 'question'
? page.getByText('Which private deployment target?', { exact: true })
: page.getByText('Yes, allow once', { exact: true }),
).toBeVisible();
});
}
test('attempts permission once across panels and reload, then allows a manual grant @smoke', async ({
page,
}, testInfo) => {
await captureNotifications(page, {
permission: 'default',
savedPreference: false,
});
const { daemon, scenario } = await openActionPanel(
page,
String(testInfo.project.use.baseURL),
'approval',
);
await expect
.poll(() =>
page.evaluate(() => window.__taskNotificationPermissionRequests),
)
.toBe(1);
await daemon.sendEvent({
id: 2,
v: 1,
type: 'permission_resolved',
data: {
requestId: 'action-1',
outcome: { outcome: 'selected', optionId: 'allow_once' },
},
});
const question = actionRequest(scenario.sessionId, 'question', 3);
await daemon.sendEvent(question);
await expect(
page.getByText('Which private deployment target?', { exact: true }),
).toBeVisible();
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(1);
scenario.events = [question];
await page.reload();
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(
page.getByText('Which private deployment target?', { exact: true }),
).toBeVisible();
const button = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
await button.focus();
const help = page.locator('[data-web-shell-notification-help]');
await expect(help).toContainText('Notifications are not authorized yet.');
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(1);
await page.evaluate(() => {
window.__taskHoldNotificationPermission = true;
});
await help
.getByRole('button', { name: 'Allow notifications', exact: true })
.click();
await expect(
help.getByRole('button', { name: 'Allow notifications', exact: true }),
).toBeDisabled();
await page.mouse.move(10, 10);
await page.waitForTimeout(350);
await expect(help).toBeVisible();
await expect(help).toContainText('Waiting for your permission…');
await page.evaluate(() =>
window.__taskResolveNotificationPermission?.('granted'),
);
await expect(help.getByRole('status')).toHaveText('Enabled for this site.');
await expect(button).toBeFocused();
await expect(help).toContainText('Enabled for this site.');
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(2);
expect(daemon.permissionRequests()).toHaveLength(0);
});
for (const permission of ['default', 'denied', 'unavailable'] as const) {
test(`explains ${permission} notifications without an automatic prompt`, async ({
page,
}, testInfo) => {
await captureNotifications(page, {
enabled: false,
permission,
savedPreference: permission === 'default',
});
await openActionPanel(
page,
String(testInfo.project.use.baseURL),
'approval',
);
const button = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
await button.click();
const help = page.locator('[data-web-shell-notification-help]');
await expect(help).toBeVisible();
await expect(button).not.toHaveAttribute('aria-pressed');
await expect(help).toContainText(/site|browser|Browser/);
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(0);
if (permission === 'default') {
await expect(
help.getByRole('button', { name: 'Allow notifications', exact: true }),
).toBeVisible();
} else {
await expect(
help.getByRole('button', { name: 'Allow notifications', exact: true }),
).toBeDisabled();
await expect(help.getByRole('status')).toHaveText(
permission === 'denied'
? 'Notifications are blocked. Allow them in your browser site settings.'
: 'Notifications are unavailable in this browser or page context.',
);
}
});
}
test('shows localized notification help in the question header', async ({
page,
}, testInfo) => {
await captureNotifications(page);
await openActionPanel(
page,
String(testInfo.project.use.baseURL),
'question',
'zh-CN',
);
await page.getByRole('button', { name: '任务通知', exact: true }).hover();
const help = page.locator('[data-web-shell-notification-help]');
await expect(help).toBeVisible();
await expect(help).toContainText(
'页面在后台或者切换到其他页签时,当前聊天需要审批、等待回答、回合结束或失败会发送提醒,点击通知返回对应聊天,不会自动审批或回答。',
);
await waitForAnimations(page);
await expect
.poll(() =>
help.evaluate((node) => {
const bounds = node.getBoundingClientRect();
return bounds.top >= 0 && bounds.bottom <= window.innerHeight;
}),
)
.toBe(true);
await page.screenshot({
path: '../../.qwen/e2e-tests/browser-action-notifications/question-popover-zh-CN.png',
fullPage: true,
});
});
test('enables an explicitly off preference with existing browser permission', async ({
page,
}, testInfo) => {
await captureNotifications(page, false);
await openActionPanel(page, String(testInfo.project.use.baseURL), 'question');
const button = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
await button.click();
const help = page.locator('[data-web-shell-notification-help]');
await expect(help.getByRole('status')).toHaveText('Disabled.');
await help
.getByRole('button', { name: 'Enable notifications', exact: true })
.click();
await expect(help.getByRole('status')).toHaveText('Enabled for this site.');
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(0);
});
for (const kind of ['approval', 'question'] as const) {
test(`keeps notification help open while dragging to select text in the ${kind} panel`, async ({
page,
}, testInfo) => {
await page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write']);
await captureNotifications(page);
const { daemon } = await openActionPanel(
page,
String(testInfo.project.use.baseURL),
kind,
);
await page
.getByRole('button', { name: 'Task notifications', exact: true })
.hover();
const help = page.locator('[data-web-shell-notification-help]');
await expect(help).toBeVisible();
await waitForAnimations(page);
const text = help.getByText(/^While this page is in the background/);
const bounds = await text.boundingBox();
expect(bounds).not.toBeNull();
const start = { x: bounds!.x + 5, y: bounds!.y + 8 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 180, start.y + 20, { steps: 8 });
await page.mouse.up();
await expect(help).toBeVisible();
const selectedText = await page.evaluate(
() => window.getSelection()?.toString() ?? '',
);
expect(selectedText.length).toBeGreaterThan(0);
await page.keyboard.press('ControlOrMeta+C');
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(selectedText);
expect(daemon.permissionRequests()).toHaveLength(0);
});
}
for (const kind of ['approval', 'question'] as const) {
test(`isolates hover-opened help while a ${kind} option retains focus @smoke`, async ({
page,
}, testInfo) => {
await captureNotifications(page);
const { daemon } = await openActionPanel(
page,
String(testInfo.project.use.baseURL),
kind,
);
const option = page.getByRole('radio', {
name: kind === 'question' ? /Staging/ : 'Yes, allow once',
});
const bell = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
const help = page.locator('[data-web-shell-notification-help]');
await option.focus();
await bell.hover();
await expect(help).toBeVisible();
await expect(option).toBeFocused();
const selected = await page
.getByRole('radio', { checked: true })
.allTextContents();
for (const key of ['1', 'Control+Enter']) {
await page.keyboard.press(key);
await expect(help).toBeVisible();
expect(daemon.permissionRequests()).toHaveLength(0);
expect(
await page.getByRole('radio', { checked: true }).allTextContents(),
).toEqual(selected);
}
await page.keyboard.press('Escape');
await expect(help).not.toBeVisible();
await expect(option).toBeFocused();
expect(daemon.permissionRequests()).toHaveLength(0);
await page.keyboard.press('Escape');
await expect.poll(() => daemon.permissionRequests().length).toBe(1);
expect(daemon.permissionRequests()[0].body).toEqual({
outcome: { outcome: 'selected', optionId: 'cancel' },
});
});
}
test('does not repeat a dismissed manual authorization when an action arrives @smoke', async ({
page,
}, testInfo) => {
await captureNotifications(page, {
permission: 'default',
savedPreference: false,
});
const scenario = createWebShellDaemonScenario({ events: [] });
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(page.getByRole('banner')).toContainText(scenario.displayName);
await page.evaluate(() => {
window.__taskHoldNotificationPermission = true;
});
await page.getByRole('button', { name: 'More', exact: true }).click();
await page
.getByRole('button', { name: 'Settings', exact: true })
.first()
.click();
await page
.getByRole('navigation', { name: 'Settings' })
.getByRole('button', { name: /^UI/ })
.click();
await page
.getByRole('button', { name: 'Allow notifications', exact: true })
.click();
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(1);
await page.getByTestId('panel-back').click();
const event = actionRequest(scenario.sessionId, 'question');
await daemon.sendEvent(event);
const bell = page.getByRole('button', {
name: 'Task notifications',
exact: true,
});
await expect(bell).toHaveAttribute('title', 'Waiting for your permission…');
await page.evaluate(() =>
window.__taskResolveNotificationPermission?.('default'),
);
await expect(bell).toHaveAttribute(
'title',
'Notifications are not authorized yet.',
);
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(1);
scenario.events = [event];
await page.reload();
await daemon.sse.waitForConnection(scenario.sessionId);
await expect(bell).toHaveAttribute(
'title',
'Notifications are not authorized yet.',
);
expect(
await page.evaluate(() => window.__taskNotificationPermissionRequests),
).toBe(1);
});
@@ -205,7 +205,7 @@ test('live daemon settings honor host inclusion and exclusion lists @smoke', asy
).toBeVisible();
await expect(page.getByRole('combobox')).toHaveCount(1);
await expect(
page.getByText('Browser task notifications', { exact: true }),
page.getByText('Task notifications', { exact: true }),
).toHaveCount(0);
}
}
+32 -4
View File
@@ -4303,9 +4303,9 @@ const EN: Messages = {
'localControl.disabledHint':
'Local Control is off. Turn it on in Settings to pair a phone on the same network.',
'localControl.openSettings': 'Open Settings',
'browserNotifications.label': 'Browser task notifications',
'browserNotifications.label': 'Task notifications',
'browserNotifications.description':
'Notify when the current chat or a split-view chat finishes or fails while this page is in the background or unfocused. Shows the session title, prompt and reply excerpts. Saved for this browser site only; the page must remain open.',
'Notify when the current chat or a split-view chat finishes, fails, needs approval or waits for your answer while this page is in the background or unfocused. Completed turns show prompt and reply excerpts; action reminders show the session title and waiting status. Saved for this browser site only; the page must remain open.',
'browserNotifications.prompt': (v) => `Prompt: ${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `Reply: ${v?.text ?? ''}`,
'browserNotifications.completed': 'This turn has completed.',
@@ -4313,7 +4313,22 @@ const EN: Messages = {
'This turn failed. Return to view the details.',
'browserNotifications.ended':
'This turn has ended. Return to check the result.',
'browserNotifications.approval':
'This session needs your approval. Return to review the request.',
'browserNotifications.question':
'This session is waiting for your answer. Return to respond.',
'browserNotifications.allow': 'Allow notifications',
'browserNotifications.enable': 'Enable notifications',
'browserNotifications.helpEnabled': 'Enabled for this site.',
'browserNotifications.helpWaiting': 'Notifications are not authorized yet.',
'browserNotifications.helpWhen':
'While this page is in the background or you switch to another tab, get reminders when the current chat needs approval, waits for your answer, finishes or fails. Clicking a notification returns to its chat without approving or answering.',
'browserNotifications.helpSystem':
'If no reminders appear, follow the steps below to check system notifications.',
'browserNotifications.helpMac':
'Open System Settings → Notifications. Select your browser (for Chrome, also check Google Chrome Helper (Alerts) if listed), enable notifications and desktop banners/alerts. Check Focus and whether notifications are allowed during screen sharing.',
'browserNotifications.helpWindows':
'Open Settings → System → Notifications (Windows 10: Notifications & actions). Enable notifications and your browser, including notification banners. Turn off Do not disturb / Focus assist, or allow your browser through its rules.',
'browserNotifications.enabled': 'Enabled.',
'browserNotifications.disabled': 'Disabled.',
'browserNotifications.waiting': 'Waiting for browser permission.',
@@ -8355,15 +8370,28 @@ const ZH: Messages = {
'localControl.disabledHint':
'本地控制未开启。请在设置中开启后,配对同一网络下的手机。',
'localControl.openSettings': '打开设置',
'browserNotifications.label': '浏览器任务通知',
'browserNotifications.label': '任务通知',
'browserNotifications.description':
'页面在后台或窗口失焦时,提醒当前聊天和分屏聊天的回合结束或失败。通知包含会话标题、提问和回复摘录。仅保存在此浏览器站点;网页需保持打开。',
'页面在后台或窗口失焦时,提醒当前聊天和分屏聊天的回合结束、失败、需要审批或等待回答。回合结束通知包含提问和回复摘录;待处理提醒只显示会话标题和等待状态。仅保存在此浏览器站点;网页需保持打开。',
'browserNotifications.prompt': (v) => `提问:${v?.text ?? ''}`,
'browserNotifications.reply': (v) => `回复:${v?.text ?? ''}`,
'browserNotifications.completed': '本轮已完成。',
'browserNotifications.failed': '本轮执行失败,请返回查看。',
'browserNotifications.ended': '本轮已结束,请返回查看结果。',
'browserNotifications.approval': '此会话需要你的审批,请返回查看请求。',
'browserNotifications.question': '此会话正在等待你的回答,请返回处理。',
'browserNotifications.allow': '允许通知',
'browserNotifications.enable': '开启通知',
'browserNotifications.helpEnabled': '已为此站点开启通知。',
'browserNotifications.helpWaiting': '尚未获得浏览器通知授权。',
'browserNotifications.helpWhen':
'页面在后台或者切换到其他页签时,当前聊天需要审批、等待回答、回合结束或失败会发送提醒,点击通知返回对应聊天,不会自动审批或回答。',
'browserNotifications.helpSystem':
'如果没有收到提醒,请按下方指引检查系统通知',
'browserNotifications.helpMac':
'打开“系统设置 → 通知”,选择浏览器(Chrome 如列有 Google Chrome Helper (Alerts),也需检查),开启允许通知及桌面横幅/提醒。检查专注模式,以及屏幕共享时是否允许通知。',
'browserNotifications.helpWindows':
'打开“设置 → 系统 → 通知”(Windows 10 为“通知和操作”),开启通知及浏览器通知,并允许显示通知横幅。关闭“请勿打扰/专注助手”,或在其规则中允许浏览器通知。',
'browserNotifications.enabled': '已开启。',
'browserNotifications.disabled': '未开启。',
'browserNotifications.waiting': '等待浏览器授权。',
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { PermissionRequest } from '../adapters/types';
import { isAskUserQuestionToolName } from '../components/messages/toolFormatting';
import type { PermissionRequest } from '../adapters/types.js';
import { isAskUserQuestionToolName } from '../constants/toolNames.js';
/**
* True when a pending permission is an AskUserQuestion prompt (it carries a