Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/convergence/protocol-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`

<details><summary>21 active identifiers</summary>
<details><summary>22 active identifiers</summary>

- `desktop_chat_resource.v1`
- `desktop_clipboard_files.v1`
Expand All @@ -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`

</details>
Expand Down
14 changes: 14 additions & 0 deletions docs/images/reply-notifications/README.md
Original file line number Diff line number Diff line change
@@ -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 的窗口
会禁用该设置并显示原因。系统通知的实际展示也受浏览器权限与系统勿扰模式影响。
Binary file added docs/images/reply-notifications/after-narrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/reply-notifications/after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/reply-notifications/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ Agent 建议固定任务要求时,问题会显示在输入框上方的独立
上下文比例来自最近一次模型请求的输入估计和应用规划窗口,未取得窗口信息时不显示。
新对话产生统计数据后才显示此栏。

### 回复提醒

在 **设置 → 常规 → 通知** 中选择“离开当前聊天时”或“始终提醒”,再点击“允许通知”
授予当前浏览器的通知权限。默认关闭;偏好保存在本机。可以发送测试通知检查权限,
点击回复通知会打开对应聊天。切换服务连接后,原连接的通知不会跳到新服务。

本轮支持当前窗口发送消息后收到的成功回复;窗口需保持打开。等待审批、排队确认、
任务调整确认和重试返回的旧回执不会被当作新回复。当前窗口不支持通知时,设置会说明原因;
系统勿扰模式和浏览器通知权限仍决定提醒是否显示。

### 任务与执行

In Web and Desktop, a newly created task opens automatically only while its
Expand Down
1 change: 1 addition & 0 deletions protocols/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
43 changes: 43 additions & 0 deletions web/src/v2/components/notification-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LocaleProvider><V2NotificationSettings /></LocaleProvider>);
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(<LocaleProvider><V2NotificationSettings /></LocaleProvider>);
fireEvent.click(screen.getByRole("button", { name: "允许通知" }));
await waitFor(() => expect(screen.getByText(/通知已被阻止/)).toBeVisible());
view.unmount();
vi.stubGlobal("Notification", undefined);
render(<LocaleProvider><V2NotificationSettings /></LocaleProvider>);
expect(screen.getByLabelText("回复提醒方式")).toBeDisabled();
expect(screen.getByText("当前窗口不支持系统通知。")).toBeVisible();
});

it("shows a storage failure without presenting the preference as saved", () => {
render(<LocaleProvider><V2NotificationSettings /></LocaleProvider>);
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");
});
54 changes: 54 additions & 0 deletions web/src/v2/components/notification-settings.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="v2-settings-section"><h2>{t("通知", "Notifications")}</h2>
<div className="v2-settings-card">
<div className="v2-setting-row"><div><strong>{t("回复提醒", "Reply notifications")}</strong>
<span>{t("仅限本窗口发送的消息,窗口需保持打开。", "Only for messages sent from this window. Keep the window open.")}</span></div>
<div className="v2-setting-value"><select aria-label={t("回复提醒方式", "Reply notification mode")} value={mode}
disabled={permission === "unsupported"} onChange={(event) => save(event.target.value as TurnNotificationMode)}>
<option value="off">{t("关闭", "Off")}</option>
<option value="background">{t("离开当前聊天时", "When away from the chat")}</option>
<option value="always">{t("始终提醒", "Always")}</option>
</select></div>
</div>
<div className="v2-setting-row"><div><strong>{t("系统通知", "System notifications")}</strong>{permission !== "default" && <span>
{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")}
</span>}</div><div className="v2-setting-value">
{permission === "default" && <button className="v2-setting-link" type="button" disabled={pending} onClick={() => void request()}>
{pending ? t("等待允许…", "Waiting…") : t("允许通知", "Allow notifications")}</button>}
{permission === "granted" && <button className="v2-setting-link" type="button" onClick={() => {
const shown = showTurnNotification("Universal-Code", t("通知已准备就绪。", "Notifications are ready."));
setError(shown ? "" : t("通知未能发送,请检查系统设置。", "Could not send the notification. Check system settings."));
}}>{t("发送测试通知", "Send test notification")}</button>}
</div></div>
</div>
{error && <p role="alert">{error}</p>}
</section>;
}
2 changes: 2 additions & 0 deletions web/src/v2/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -193,6 +194,7 @@ function GeneralSettings({ client, threadID, workspaces, onPermissions }: {
</div>
</section>
<V2ProjectlessDirectorySettings client={client} />
<V2NotificationSettings />
</>;
}

Expand Down
101 changes: 101 additions & 0 deletions web/src/v2/turn-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> }> = [];
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 });
});
61 changes: 61 additions & 0 deletions web/src/v2/turn-notifications.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading