diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index 1e3d9d0e..f851289a 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -21,7 +21,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [credential-provider-ledgers](#credential-provider-ledgers) | `internal-durable` | Credential, provider, model-route, and pricing maintainers | 24 | true | | [desktop-risk-restart-session](#desktop-risk-restart-session) | `ephemeral` | Desktop shell lifecycle maintainers | 2 | false | | [desktop-verification-settings](#desktop-verification-settings) | `internal-durable` | Desktop verification settings maintainers | 1 | true | -| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 21 | true | +| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 22 | true | | [docker-attach-process-session](#docker-attach-process-session) | `ephemeral` | Docker runtime transport maintainers | 1 | false | | [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true | | [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 48 | true | @@ -782,7 +782,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - Readers: - `desktop-web-presentation-state-reader` (`v1`, active) at `web/src` -
21 active identifiers +
22 active identifiers - `desktop_chat_resource.v1` - `desktop_clipboard_files.v1` @@ -804,6 +804,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `universal-code.chat-preferences.v1` - `universal-code.sidebar-width.v1` - `universal-code.specialist-continuation.v1` +- `universal-code.turn-notifications.v1` - `v2_file_upload.v1`
diff --git a/docs/images/reply-notifications/README.md b/docs/images/reply-notifications/README.md new file mode 100644 index 00000000..21a24fe6 --- /dev/null +++ b/docs/images/reply-notifications/README.md @@ -0,0 +1,14 @@ +# 回复提醒页面验证 + +截图由真实 Chromium 加载生产构建并连接 `cmd/workbenchjourney` 的临时服务后拍摄。 +`before.png` 为父提交 `10311148`,`after.png` 为此 PR 的设置页;两张均为 1280 × 1200。 +`after-narrow.png` 为 600 × 900 窗口滚动到通知设置后的画面。没有用静态 HTML 代替应用。 + +已验证:默认关闭、浏览器通知授权、模式选择、测试通知;在纯文本输入中发送测试任务, +真实服务执行文件读取并生成公开回复后,页面调用原生 Notification 构造器生成一次回复通知。 +测试时仅用透明 Proxy 记录构造器参数,仍调用浏览器原生实现;没有替换通知返回结果。 +首次在富文本编辑器中输入的 harness 标记被 Markdown 转义,临时 Provider 拒绝该测试请求; +改用该 harness 既有纯文本路径后完成。失败回执未触发回复通知。 + +已检查窄窗口布局。本批未验证原生桌面窗口的操作系统通知弹层;不支持 Notification 的窗口 +会禁用该设置并显示原因。系统通知的实际展示也受浏览器权限与系统勿扰模式影响。 diff --git a/docs/images/reply-notifications/after-narrow.png b/docs/images/reply-notifications/after-narrow.png new file mode 100644 index 00000000..1050dcdf Binary files /dev/null and b/docs/images/reply-notifications/after-narrow.png differ diff --git a/docs/images/reply-notifications/after.png b/docs/images/reply-notifications/after.png new file mode 100644 index 00000000..7171c1e5 Binary files /dev/null and b/docs/images/reply-notifications/after.png differ diff --git a/docs/images/reply-notifications/before.png b/docs/images/reply-notifications/before.png new file mode 100644 index 00000000..34c10790 Binary files /dev/null and b/docs/images/reply-notifications/before.png differ diff --git a/docs/usage.md b/docs/usage.md index ae75b995..b54216d7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -213,6 +213,16 @@ Agent 建议固定任务要求时,问题会显示在输入框上方的独立 上下文比例来自最近一次模型请求的输入估计和应用规划窗口,未取得窗口信息时不显示。 新对话产生统计数据后才显示此栏。 +### 回复提醒 + +在 **设置 → 常规 → 通知** 中选择“离开当前聊天时”或“始终提醒”,再点击“允许通知” +授予当前浏览器的通知权限。默认关闭;偏好保存在本机。可以发送测试通知检查权限, +点击回复通知会打开对应聊天。切换服务连接后,原连接的通知不会跳到新服务。 + +本轮支持当前窗口发送消息后收到的成功回复;窗口需保持打开。等待审批、排队确认、 +任务调整确认和重试返回的旧回执不会被当作新回复。当前窗口不支持通知时,设置会说明原因; +系统勿扰模式和浏览器通知权限仍决定提醒是否显示。 + ### 任务与执行 In Web and Desktop, a newly created task opens automatically only while its diff --git a/protocols/registry.json b/protocols/registry.json index 73c8d152..75c32018 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -1318,6 +1318,7 @@ "universal-code.chat-preferences.v1", "universal-code.sidebar-width.v1", "universal-code.specialist-continuation.v1", + "universal-code.turn-notifications.v1", "v2_file_upload.v1" ], "retired_identifiers": [ diff --git a/web/src/v2/components/notification-settings.test.tsx b/web/src/v2/components/notification-settings.test.tsx new file mode 100644 index 00000000..0e5636ce --- /dev/null +++ b/web/src/v2/components/notification-settings.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { LocaleProvider } from "../../lib/locale"; +import { readTurnNotificationMode } from "../turn-notifications"; +import { V2NotificationSettings } from "./notification-settings"; + +const requestPermission = vi.fn(); +beforeEach(() => { + localStorage.clear(); requestPermission.mockReset(); + vi.stubGlobal("Notification", class { static permission = "default"; static requestPermission = requestPermission; }); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it("requests permission only after clicking Allow and persists the chosen mode", async () => { + requestPermission.mockResolvedValue("granted"); + render(); + expect(requestPermission).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("回复提醒方式"), { target: { value: "background" } }); + expect(readTurnNotificationMode()).toBe("background"); + expect(requestPermission).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "允许通知" })); + await waitFor(() => expect(screen.getByRole("button", { name: "发送测试通知" })).toBeVisible()); + expect(requestPermission).toHaveBeenCalledOnce(); +}); + +it("explains denied or unsupported notification delivery", async () => { + requestPermission.mockResolvedValue("denied"); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "允许通知" })); + await waitFor(() => expect(screen.getByText(/通知已被阻止/)).toBeVisible()); + view.unmount(); + vi.stubGlobal("Notification", undefined); + render(); + expect(screen.getByLabelText("回复提醒方式")).toBeDisabled(); + expect(screen.getByText("当前窗口不支持系统通知。")).toBeVisible(); +}); + +it("shows a storage failure without presenting the preference as saved", () => { + render(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + fireEvent.change(screen.getByLabelText("回复提醒方式"), { target: { value: "always" } }); + expect(screen.getByRole("alert")).toHaveTextContent("提醒偏好未能保存"); + expect(screen.getByLabelText("回复提醒方式")).toHaveValue("off"); +}); diff --git a/web/src/v2/components/notification-settings.tsx b/web/src/v2/components/notification-settings.tsx new file mode 100644 index 00000000..ab5a1d2a --- /dev/null +++ b/web/src/v2/components/notification-settings.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { useLocale } from "../../lib/locale"; +import { readTurnNotificationMode, saveTurnNotificationMode, showTurnNotification, + turnNotificationPermission, type TurnNotificationMode } from "../turn-notifications"; + +export function V2NotificationSettings() { + const { t } = useLocale(); + const [mode, setMode] = useState(readTurnNotificationMode); + const [permission, setPermission] = useState(turnNotificationPermission); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + const refresh = () => { setMode(readTurnNotificationMode()); setPermission(turnNotificationPermission()); }; + window.addEventListener("focus", refresh); + window.addEventListener("storage", refresh); + return () => { window.removeEventListener("focus", refresh); window.removeEventListener("storage", refresh); }; + }, []); + const save = (next: TurnNotificationMode) => { + try { saveTurnNotificationMode(next); setMode(next); setError(""); } + catch { setError(t("提醒偏好未能保存,请检查本机存储后重试。", "Could not save notification preferences. Check local storage and try again.")); } + }; + const request = async () => { + setPending(true); setError(""); + try { setPermission(await Notification.requestPermission()); } + catch { setError(t("无法请求通知权限,请检查浏览器或系统设置。", "Could not request notification permission. Check your browser or system settings.")); } + finally { setPending(false); } + }; + return

{t("通知", "Notifications")}

+
+
{t("回复提醒", "Reply notifications")} + {t("仅限本窗口发送的消息,窗口需保持打开。", "Only for messages sent from this window. Keep the window open.")}
+
+
+
{t("系统通知", "System notifications")}{permission !== "default" && + {permission === "unsupported" ? t("当前窗口不支持系统通知。", "System notifications are unavailable in this window.") + : permission === "denied" ? t("通知已被阻止,请在浏览器或系统设置中允许。", "Notifications are blocked. Allow them in your browser or system settings.") + : t("已允许", "Allowed")} + }
+ {permission === "default" && } + {permission === "granted" && } +
+
+ {error &&

{error}

} +
; +} diff --git a/web/src/v2/components/settings.tsx b/web/src/v2/components/settings.tsx index 3ee9ec12..98302ae8 100644 --- a/web/src/v2/components/settings.tsx +++ b/web/src/v2/components/settings.tsx @@ -30,6 +30,7 @@ import { V2ProviderSettings } from "./provider-settings"; import { V2RuntimeCapabilityControl } from "./runtime-capability-control"; import { saveChatPreferences, useChatPreferences, type SendPreference } from "../chat-preferences"; import { V2KeyboardSettings } from "./keyboard-settings"; +import { V2NotificationSettings } from "./notification-settings"; import { V2ExecutionSettings } from "./execution-settings"; import { V2CommandServiceSettings } from "./command-service-settings"; import { V2ProjectlessDirectorySettings } from "./projectless-directory-settings"; @@ -193,6 +194,7 @@ function GeneralSettings({ client, threadID, workspaces, onPermissions }: { + ; } diff --git a/web/src/v2/turn-notifications.test.ts b/web/src/v2/turn-notifications.test.ts new file mode 100644 index 00000000..ab17b7df --- /dev/null +++ b/web/src/v2/turn-notifications.test.ts @@ -0,0 +1,101 @@ +import type { ThreadMessageControlView } from "../api/types"; +import { useConnectionStore } from "../state/connection"; +import { notifyTurnReply, readTurnNotificationMode, saveTurnNotificationMode, showTurnNotification } from "./turn-notifications"; + +const notifications: Array<{ title: string; options: NotificationOptions; onclick?: () => void; close: ReturnType }> = []; +class TestNotification { + static permission: NotificationPermission = "granted"; + close = vi.fn(); + onclick?: () => void; + constructor(readonly title: string, readonly options: NotificationOptions) { notifications.push(this); } +} +const reply = () => ({ version: "thread_message_submission.v1", replayed: false, execution_started: true, + model_called: true, steering: { status: "committed" }, + thread: { id: "thread-one", title: "修复登录", composer_state: "ready" } }) as ThreadMessageControlView; + +beforeEach(() => { + localStorage.clear(); notifications.length = 0; + TestNotification.permission = "granted"; + vi.stubGlobal("Notification", TestNotification); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(window, "focus").mockImplementation(() => {}); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + window.history.replaceState(null, "", "#/threads/thread-one"); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it("defaults off and never requests permissions while a reply arrives", () => { + expect(readTurnNotificationMode()).toBe("off"); + notifyTurnReply(reply()); + expect(notifications).toHaveLength(0); + localStorage.setItem("universal-code.turn-notifications.v1", "bad-value"); + expect(readTurnNotificationMode()).toBe("off"); +}); + +it("notifies only when away, and opens the exact chat on click", () => { + saveTurnNotificationMode("background"); + notifyTurnReply(reply()); + expect(notifications).toHaveLength(0); + window.history.replaceState(null, "", "#/threads/thread-two"); + notifyTurnReply(reply()); + expect(notifications).toHaveLength(1); + expect(notifications[0].options.body).toBe("修复登录"); + expect(notifications[0].options.tag).toBe("uc-reply:thread-one"); + notifications[0].onclick!(); + expect(window.location.hash).toBe("#/threads/thread-one"); + expect(notifications[0].close).toHaveBeenCalledOnce(); +}); + +it("allows background windows and settings, and localizes notification text", () => { + saveTurnNotificationMode("background"); + vi.mocked(document.hasFocus).mockReturnValue(false); + notifyTurnReply(reply()); + expect(notifications).toHaveLength(1); + vi.mocked(document.hasFocus).mockReturnValue(true); + window.history.replaceState(null, "", "#/threads/thread-one/settings/general"); + localStorage.setItem("prayu.locale.v1", "en-US"); + notifyTurnReply(reply()); + expect(notifications[1].title).toBe("Universal-Code · Reply ready"); +}); + +it("never labels queued, replayed, approval-waiting or non-model requests as replies", () => { + saveTurnNotificationMode("always"); + const cases = [ { replayed: true }, { execution_started: false }, { model_called: false }, + { steering: { status: "pending" } }, { thread: { ...reply().thread, composer_state: "waiting_approval" } }, + { version: "session_message_submission.v1" } ]; + for (const patch of cases) notifyTurnReply({ ...reply(), ...patch } as ThreadMessageControlView); + expect(notifications).toHaveLength(0); + notifyTurnReply(reply()); + expect(notifications).toHaveLength(1); +}); + +it("handles unavailable or denied APIs and delivery errors without failing the turn", () => { + saveTurnNotificationMode("always"); + TestNotification.permission = "denied"; + notifyTurnReply(reply()); + expect(notifications).toHaveLength(0); + vi.stubGlobal("Notification", undefined); + expect(showTurnNotification("test", "test")).toBe(false); + vi.stubGlobal("Notification", class { static permission = "granted"; constructor() { throw new Error("OS rejected"); } }); + expect(() => notifyTurnReply(reply())).not.toThrow(); +}); + +it("does not navigate a notification into a different connection", () => { + saveTurnNotificationMode("always"); + notifyTurnReply(reply()); + window.history.replaceState(null, "", "#/new"); + const previous = useConnectionStore.getState().apiBaseURL; + useConnectionStore.setState({ apiBaseURL: "http://different-server/api/v1" }); + notifications[0].onclick!(); + expect(window.location.hash).toBe("#/new"); + useConnectionStore.setState({ apiBaseURL: previous }); +}); + +it("ignores an old server reply received after reconnecting elsewhere", () => { + saveTurnNotificationMode("always"); + const original = useConnectionStore.getState(); + useConnectionStore.setState({ apiBaseURL: "http://different-server/api/v1" }); + notifyTurnReply(reply(), original); + expect(notifications).toHaveLength(0); + useConnectionStore.setState({ apiBaseURL: original.apiBaseURL }); +}); diff --git a/web/src/v2/turn-notifications.ts b/web/src/v2/turn-notifications.ts new file mode 100644 index 00000000..4fa739c7 --- /dev/null +++ b/web/src/v2/turn-notifications.ts @@ -0,0 +1,61 @@ +import type { SessionMessageControlView, ThreadMessageControlView } from "../api/types"; +import { readPrayuLocale } from "../lib/locale"; +import { useConnectionStore } from "../state/connection"; +import { readV2Route } from "./navigation"; + +export type TurnNotificationMode = "off" | "background" | "always"; +const preferenceKey = "universal-code.turn-notifications.v1"; + +export function readTurnNotificationMode(): TurnNotificationMode { + try { + const value = localStorage.getItem(preferenceKey); + return value === "background" || value === "always" ? value : "off"; + } catch { return "off"; } +} + +export function saveTurnNotificationMode(mode: TurnNotificationMode) { + localStorage.setItem(preferenceKey, mode); +} + +export function turnNotificationPermission(): NotificationPermission | "unsupported" { + return typeof Notification === "undefined" ? "unsupported" : Notification.permission; +} + +export function showTurnNotification(title: string, body: string, threadID?: string): boolean { + if (turnNotificationPermission() !== "granted") return false; + const connection = useConnectionStore.getState(); + try { + const notification = new Notification(title, { body, ...(threadID ? { tag: `uc-reply:${threadID}` } : {}) }); + notification.onclick = () => { + notification.close(); + window.focus(); + const current = useConnectionStore.getState(); + // A notification from a previous server must not open an unrelated chat. + if (threadID && current.health?.data_store_id === connection.health?.data_store_id && + current.apiBaseURL === connection.apiBaseURL && current.token === connection.token) { + window.location.hash = `#/threads/${encodeURIComponent(threadID)}`; + } + }; + return true; + } catch { return false; } +} + +// This runs only for a fresh reply received by this window. Queued inputs, +// steering acknowledgements, approval waits and recovery replays are not replies. +export function notifyTurnReply(result: ThreadMessageControlView | SessionMessageControlView, + connection = useConnectionStore.getState()) { + const mode = readTurnNotificationMode(); + if (mode === "off") return; + const current = useConnectionStore.getState(); + if (current.health?.data_store_id !== connection.health?.data_store_id || + current.apiBaseURL !== connection.apiBaseURL || current.token !== connection.token) return; + if (result.version !== "thread_message_submission.v1" || result.replayed || + !result.execution_started || !result.model_called || result.steering.status !== "committed" || + result.thread.composer_state === "waiting_approval") return; + const route = readV2Route(window.location.hash, window.location.pathname); + if (mode === "background" && document.visibilityState === "visible" && document.hasFocus() && + route.threadID === result.thread.id && !route.section && !route.view) return; + const english = readPrayuLocale() === "en-US"; + showTurnNotification(english ? "Universal-Code · Reply ready" : "Universal-Code · 新回复已就绪", + result.thread.title, result.thread.id); +} diff --git a/web/src/v2/use-thread-turn.ts b/web/src/v2/use-thread-turn.ts index 09342a9a..c453017f 100644 --- a/web/src/v2/use-thread-turn.ts +++ b/web/src/v2/use-thread-turn.ts @@ -12,6 +12,8 @@ import { fileAttachmentIdentities, validFileAttachments, type WorkspaceFileAttac import { v2AttachmentReferenceKey } from "./attachment-keys"; import { mcpReferenceIdentities, validMCPReferences, type MCPDraftReference } from "../api/mcp-references"; import { v2MCPReferenceKey } from "./components/mcp-reference-picker"; +import { notifyTurnReply } from "./turn-notifications"; +import { useConnectionStore } from "../state/connection"; export interface V2TurnInput { threadID: string; @@ -101,6 +103,7 @@ export function useV2ThreadTurn(client: APIClient) { // server owns deduplication; a known failed turn is never executed again. retry: (failureCount, error) => failureCount < 1 && !v2TurnOutcomeKnown(error), retryDelay: 400, + onMutate: () => useConnectionStore.getState(), mutationFn: async (input: V2TurnInput) => { if (input.attachments !== undefined && !validFileAttachments(input.attachments, input.workspaceID)) { throw new Error("文件附件的项目或保存身份无效,未发送消息。"); @@ -137,7 +140,8 @@ export function useV2ThreadTurn(client: APIClient) { ...(input.mcpReferences?.length ? { mcp_references: mcpReferenceIdentities(input.mcpReferences) } : {}), }, input.operationKey); }, - onSuccess: (_data, input) => { + onSuccess: (data, input, connection) => { + notifyTurnReply(data, connection); try { settleRecoveryTurn(recovery, input, true); } catch { /* Keep the journal until a later read confirms it. */ } // Versioned drafts are consumed by their exact branch/ref in settlement. // Keep identity-based cache cleanup only for legacy journal entries.