From fad481109e5733ad91ebb5ef96d87db2eacff009 Mon Sep 17 00:00:00 2001 From: Terry <932914698@qq.com> Date: Wed, 12 Aug 2026 12:17:00 +0800 Subject: [PATCH 1/4] feat(notifications): distinguish agent turn outcomes --- src/contexts/acp-connections-context.test.tsx | 38 +++++++++++++++++++ src/contexts/acp-connections-context.tsx | 16 ++++++-- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/notification.test.ts | 36 ++++++++++++++++++ src/lib/notification.ts | 2 +- 14 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/lib/notification.test.ts diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index e895d82de..9e423da1b 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -944,6 +944,44 @@ describe("out-of-turn wire guard + background activity", () => { return latestAttachHandlers() } + it("distinguishes completed, interrupted, and failed turns in OS notifications", async () => { + const handlers = await mountOwnerConnection() + h.sendSystemNotification.mockClear() + + for (const [index, stopReason] of ["end_turn", "cancelled"].entries()) { + emitAcpEvent(handlers, { + seq: index + 1, + connection_id: "spawned-conn", + type: "turn_complete", + session_id: "sess-1", + stop_reason: stopReason, + agent_type: "codex", + }) + } + emitAcpEvent(handlers, { + seq: 3, + connection_id: "spawned-conn", + type: "error", + message: "Codex refused the prompt", + agent_type: "codex", + code: "turn_failed_refusal", + }) + emitAcpEvent(handlers, { + seq: 4, + connection_id: "spawned-conn", + type: "turn_complete", + session_id: "sess-1", + stop_reason: "refusal", + agent_type: "codex", + }) + + expect(h.sendSystemNotification.mock.calls.map((call) => call[1])).toEqual([ + "notificationTurnComplete", + "notificationTurnCancelled", + "notificationError", + ]) + }) + it("drops streaming deltas while the connection is not prompting (Bug-A guard)", async () => { const handlers = await mountOwnerConnection() diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 98f50ee44..444b020cb 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -3494,10 +3494,18 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const agentLabel = getAgentLabel(nc.agentType) const fn = folderNameRef.current const title = fn ? `${fn} - Codeg` : "Codeg" - sendSystemNotification( - title, - t("notificationTurnComplete", { agent: agentLabel }) - ).catch(() => {}) + const key = + e.stop_reason === "end_turn" + ? "notificationTurnComplete" + : e.stop_reason === "cancelled" + ? "notificationTurnCancelled" + : null + if (key) { + sendSystemNotification( + title, + t(key, { agent: agentLabel }) + ).catch(() => {}) + } } } break diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 831837f50..4c90f410a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "أداة", "eventErrorTitle": "خطأ الوكيل", "notificationTurnComplete": "{agent} أنهى الاستجابة", + "notificationTurnCancelled": "تمت مقاطعة مهمة {agent}", "notificationError": "{agent} خطأ: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index fd4891302..a6a64b7ed 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "Werkzeug", "eventErrorTitle": "Agentenfehler", "notificationTurnComplete": "{agent} hat die Antwort abgeschlossen", + "notificationTurnCancelled": "Die Aufgabe von {agent} wurde unterbrochen", "notificationError": "{agent} Fehler: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d620504cd..589e1a0d5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "Tool", "eventErrorTitle": "Agent Error", "notificationTurnComplete": "{agent} has finished responding", + "notificationTurnCancelled": "{agent} task was interrupted", "notificationError": "{agent} error: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e3eb61b81..312891670 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "Herramienta", "eventErrorTitle": "Error del agente", "notificationTurnComplete": "{agent} ha terminado de responder", + "notificationTurnCancelled": "La tarea de {agent} fue interrumpida", "notificationError": "{agent} error: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index b17ba21b3..6b42fb3bf 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "Outil", "eventErrorTitle": "Erreur de l'agent", "notificationTurnComplete": "{agent} a terminé de répondre", + "notificationTurnCancelled": "La tâche de {agent} a été interrompue", "notificationError": "{agent} erreur : {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d48c75b61..86552ee17 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "ツール", "eventErrorTitle": "エージェントエラー", "notificationTurnComplete": "{agent} の応答が完了しました", + "notificationTurnCancelled": "{agent} のタスクが中断されました", "notificationError": "{agent} エラー:{message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 23a001aa4..80323250a 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "도구", "eventErrorTitle": "에이전트 오류", "notificationTurnComplete": "{agent} 응답이 완료되었습니다", + "notificationTurnCancelled": "{agent} 작업이 중단되었습니다", "notificationError": "{agent} 오류: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index bfefcb612..c8139a66b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "Ferramenta", "eventErrorTitle": "Erro do agente", "notificationTurnComplete": "{agent} terminou de responder", + "notificationTurnCancelled": "A tarefa de {agent} foi interrompida", "notificationError": "{agent} erro: {message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 92cb36f54..5b065fc52 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "工具", "eventErrorTitle": "Agent 错误", "notificationTurnComplete": "{agent} 已完成响应", + "notificationTurnCancelled": "{agent} 任务已中断", "notificationError": "{agent} 错误:{message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 98f36ea15..db543de31 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2507,6 +2507,7 @@ "toolFallbackTitle": "工具", "eventErrorTitle": "Agent 錯誤", "notificationTurnComplete": "{agent} 已完成回應", + "notificationTurnCancelled": "{agent} 任務已中斷", "notificationError": "{agent} 錯誤:{message}", "claudeApiRetry": { "fallbackError": "authentication_failed", diff --git a/src/lib/notification.test.ts b/src/lib/notification.test.ts new file mode 100644 index 000000000..07c2ab292 --- /dev/null +++ b/src/lib/notification.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const h = vi.hoisted(() => ({ + call: vi.fn(async () => undefined), +})) + +vi.mock("./transport", () => ({ + getTransport: () => ({ call: h.call }), + isDesktop: () => true, +})) + +import { sendSystemNotification } from "./notification" + +describe("sendSystemNotification", () => { + beforeEach(() => { + h.call.mockClear() + vi.spyOn(document, "hasFocus").mockReturnValue(true) + }) + + it("stays quiet while Codeg is focused", async () => { + await sendSystemNotification("Codeg", "done") + + expect(h.call).not.toHaveBeenCalled() + }) + + it("uses the native notification when Codeg loses focus", async () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false) + + await sendSystemNotification("Codeg", "done") + + expect(h.call).toHaveBeenCalledWith("send_notification", { + title: "Codeg", + body: "done", + }) + }) +}) diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 5c538d9d0..dc72411b3 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -5,7 +5,7 @@ export async function sendSystemNotification( title: string, body: string ): Promise { - if (!document.hidden) return + if (!document.hidden && document.hasFocus()) return if (isDesktop()) { await getTransport().call("send_notification", { title, body }) } else { From 2883de2d26e62ba4696271ee3de948103e5dc0f4 Mon Sep 17 00:00:00 2001 From: Terry <932914698@qq.com> Date: Wed, 12 Aug 2026 15:20:19 +0800 Subject: [PATCH 2/4] feat(notifications): identify workspace and conversation --- src-tauri/tauri.conf.json | 2 +- src/contexts/acp-connections-context.test.tsx | 45 +++++- src/contexts/acp-connections-context.tsx | 145 +++++++++++++++--- src/lib/notification.test.ts | 41 ++++- src/lib/notification.ts | 2 +- 5 files changed, 206 insertions(+), 29 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 88dd4561b..72393e4f2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "codeg", + "productName": "Codeg", "version": "0.24.0", "identifier": "app.codeg", "build": { diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index 9e423da1b..8d219cd37 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -4,6 +4,8 @@ import { useTranslations } from "next-intl" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { AcpConnectionsProvider, + buildNotificationTitle, + notificationSummary, useAcpActions, useConnectionStore, } from "@/contexts/acp-connections-context" @@ -129,6 +131,21 @@ async function mountProvider() { const TAB = "conv-1-claude_code-42" +describe("notificationSummary", () => { + it("normalizes whitespace and bounds notification-center payloads", () => { + expect(notificationSummary(" result\n\nready ")).toBe("result ready") + const summary = notificationSummary("x".repeat(400)) + expect(summary).toHaveLength(300) + expect(summary).toMatch(/\.\.\.$/) + }) + + it("formats workspace, conversation window, and result status", () => { + expect( + buildNotificationTitle("Codeg 二开", "任务状态通知", "Codex 已完成") + ).toBe("Codeg 二开 / 任务状态通知 - Codex 已完成") + }) +}) + beforeEach(() => { h.attach.mockClear() h.store = null @@ -978,10 +995,36 @@ describe("out-of-turn wire guard + background activity", () => { expect(h.sendSystemNotification.mock.calls.map((call) => call[1])).toEqual([ "notificationTurnComplete", "notificationTurnCancelled", - "notificationError", + "backendErrors.turnFailedRefusal", ]) }) + it("includes the question result when Codex waits for an answer", async () => { + const handlers = await mountOwnerConnection() + h.sendSystemNotification.mockClear() + + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "question_request", + question_id: "question-1", + questions: [ + { + id: "scope", + header: "Scope", + question: "Should the notification include the final result?", + multi_select: false, + options: [], + }, + ], + }) + + expect(h.sendSystemNotification).toHaveBeenCalledWith( + "x - Claude Code questionDialog.title", + "Should the notification include the final result?" + ) + }) + it("drops streaming deltas while the connection is not prompting (Bug-A guard)", async () => { const handlers = await mountOwnerConnection() diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 444b020cb..c5e6e2221 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -40,6 +40,8 @@ import { getConversationIdByExternalIdFromStore, useConversationRuntimeStore, } from "@/stores/conversation-runtime-store" +import { useTabStore } from "@/stores/tab-store" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import type { AgentType, AcpAgentStatus, @@ -167,6 +169,40 @@ export interface LiveMessage { startedAt: number } +const NOTIFICATION_SUMMARY_MAX_LENGTH = 300 + +export function notificationSummary( + text: string | null | undefined +): string | null { + const normalized = text?.replace(/\s+/g, " ").trim() + if (!normalized) return null + if (normalized.length <= NOTIFICATION_SUMMARY_MAX_LENGTH) return normalized + return `${normalized.slice(0, NOTIFICATION_SUMMARY_MAX_LENGTH - 3)}...` +} + +export function buildNotificationTitle( + workspace: string, + conversation: string | null | undefined, + status: string +): string { + const name = conversation?.trim() + return name + ? `${workspace} / ${name} - ${status}` + : `${workspace} - ${status}` +} + +function lastAssistantText(liveMessage: LiveMessage | null): string | null { + if (!liveMessage) return null + const text = liveMessage.content + .filter( + (block): block is Extract => + block.type === "text" && !block.parentToolUseId + ) + .map((block) => block.text) + .join("\n") + return notificationSummary(text) +} + // ── Per-connection state ── export interface ConnectionState { @@ -2597,6 +2633,41 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { useEffect(() => { folderNameRef.current = folder?.name }, [folder?.name]) + + const notificationTitle = useCallback( + (connection: ConnectionState, status: string): string => { + const indexedConversationId = connection.sessionId + ? getConversationIdByExternalIdFromStore(connection.sessionId) + : null + const contextConversationId = Number( + /-(\d+)$/.exec(connection.contextKey)?.[1] + ) + const conversationId = + indexedConversationId ?? + (Number.isSafeInteger(contextConversationId) + ? contextConversationId + : null) + const tab = useTabStore + .getState() + .tabs.find( + (candidate) => + candidate.conversationId === conversationId || + candidate.runtimeConversationId === conversationId + ) + const workspace = + (tab + ? useAppWorkspaceStore + .getState() + .allFolders.find((candidate) => candidate.id === tab.folderId) + ?.name + : null) ?? + folderNameRef.current ?? + "Codeg" + const conversation = tab?.title?.trim() + return buildNotificationTitle(workspace, conversation, status) + }, + [] + ) const pushAlertRef = useRef(pushAlert) useEffect(() => { pushAlertRef.current = pushAlert @@ -3141,6 +3212,23 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { created_at: new Date().toISOString(), }, }) + { + const nc = storeRef.current.connections.get(contextKey) + const question = notificationSummary( + e.questions.map((item) => item.question).join(" ") + ) + if (nc && question) { + sendSystemNotification( + notificationTitle( + nc, + `${getAgentLabel(nc.agentType)} ${tChat( + "questionDialog.title" + )}` + ), + question + ).catch(() => {}) + } + } break case "question_resolved": // The question was answered (this or another window) or canceled. @@ -3166,6 +3254,21 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { created_at: new Date().toISOString(), }, }) + { + const nc = storeRef.current.connections.get(contextKey) + if (nc) { + sendSystemNotification( + notificationTitle( + nc, + `${getAgentLabel(nc.agentType)} ${tChat( + "planApproval.title" + )}` + ), + notificationSummary(e.plan_markdown) ?? + tChat("planApproval.title") + ).catch(() => {}) + } + } break case "plan_approval_resolved": // The approval was answered (this or another window) or canceled. @@ -3234,8 +3337,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { } } // 3. one OS notification per settled task (matches the permission - // notification's shape; `document.hidden` gating lives inside - // sendSystemNotification). + // notification's shape and delivery policy). if (e.settled && e.settled.length > 0) { const nc = storeRef.current.connections.get(contextKey) const agentLabel = nc ? getAgentLabel(nc.agentType) : "Agent" @@ -3299,11 +3401,12 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const nc = storeRef.current.connections.get(contextKey) if (nc) { const agentLabel = getAgentLabel(nc.agentType) - const fn = folderNameRef.current - const title = fn ? `${fn} - Codeg` : "Codeg" sendSystemNotification( - title, - `${agentLabel}: ${tChat("permissionDialog.subtitle")}` + notificationTitle( + nc, + `${agentLabel} ${tChat("questionDialog.title")}` + ), + tChat("permissionDialog.subtitle") ).catch(() => {}) } } @@ -3460,6 +3563,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { }) // Detect pending question from tool calls in the completed turn const turnConn = storeRef.current.connections.get(contextKey) + let pendingQuestionText: string | null = null if (turnConn?.liveMessage) { const blocks = turnConn.liveMessage.content for (let i = blocks.length - 1; i >= 0; i--) { @@ -3474,6 +3578,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { if (normalized === "question") { const questionText = extractQuestionText(block.info.raw_input) if (questionText) { + pendingQuestionText = questionText dispatch({ type: "SET_PENDING_QUESTION", contextKey, @@ -3487,13 +3592,12 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { } } } - // Send OS notification when window is not focused + // Desktop notifications are always delivered; the web fallback + // suppresses them while its browser tab is focused. { const nc = storeRef.current.connections.get(contextKey) if (nc) { const agentLabel = getAgentLabel(nc.agentType) - const fn = folderNameRef.current - const title = fn ? `${fn} - Codeg` : "Codeg" const key = e.stop_reason === "end_turn" ? "notificationTurnComplete" @@ -3501,9 +3605,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { ? "notificationTurnCancelled" : null if (key) { + const status = pendingQuestionText + ? `${agentLabel} ${tChat("questionDialog.title")}` + : t(key, { agent: agentLabel }) sendSystemNotification( - title, - t(key, { agent: agentLabel }) + notificationTitle(nc, status), + notificationSummary(pendingQuestionText) ?? + lastAssistantText(turnConn?.liveMessage ?? null) ?? + t(key, { agent: agentLabel }) ).catch(() => {}) } } @@ -3612,14 +3721,13 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // notification centers persist their payload outside the app, so // agent output must not be forwarded there. if (nc) { - const fn = folderNameRef.current - const title = fn ? `${fn} - Codeg` : "Codeg" sendSystemNotification( - title, - t("notificationError", { - agent: agentLabel, - message: localizedMessage, - }) + notificationTitle(nc, `${agentLabel} ${t("eventErrorTitle")}`), + notificationSummary(localizedMessage) ?? + t("notificationError", { + agent: agentLabel, + message: localizedMessage, + }) ).catch(() => {}) } break @@ -3682,6 +3790,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { scheduleToolCallUpdateFlush, t, tChat, + notificationTitle, ] ) diff --git a/src/lib/notification.test.ts b/src/lib/notification.test.ts index 07c2ab292..b4403bf11 100644 --- a/src/lib/notification.test.ts +++ b/src/lib/notification.test.ts @@ -2,11 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const h = vi.hoisted(() => ({ call: vi.fn(async () => undefined), + desktop: true, })) vi.mock("./transport", () => ({ getTransport: () => ({ call: h.call }), - isDesktop: () => true, + isDesktop: () => h.desktop, })) import { sendSystemNotification } from "./notification" @@ -14,23 +15,47 @@ import { sendSystemNotification } from "./notification" describe("sendSystemNotification", () => { beforeEach(() => { h.call.mockClear() + h.desktop = true + vi.unstubAllGlobals() + Object.defineProperty(document, "hidden", { + configurable: true, + value: false, + }) vi.spyOn(document, "hasFocus").mockReturnValue(true) }) - it("stays quiet while Codeg is focused", async () => { + it("uses the native notification even while Codeg is focused", async () => { await sendSystemNotification("Codeg", "done") - expect(h.call).not.toHaveBeenCalled() + expect(h.call).toHaveBeenCalledWith("send_notification", { + title: "Codeg", + body: "done", + }) }) - it("uses the native notification when Codeg loses focus", async () => { - vi.spyOn(document, "hasFocus").mockReturnValue(false) + it("stays quiet in a focused web browser", async () => { + h.desktop = false await sendSystemNotification("Codeg", "done") - expect(h.call).toHaveBeenCalledWith("send_notification", { - title: "Codeg", - body: "done", + expect(h.call).not.toHaveBeenCalled() + }) + + it("uses the browser notification when the web page is in the background", async () => { + h.desktop = false + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, }) + const notification = vi.fn() + Object.assign(notification, { + permission: "granted", + requestPermission: vi.fn(), + }) + vi.stubGlobal("Notification", notification) + + await sendSystemNotification("Codeg", "done") + + expect(notification).toHaveBeenCalledWith("Codeg", { body: "done" }) }) }) diff --git a/src/lib/notification.ts b/src/lib/notification.ts index dc72411b3..25b05a8e3 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -5,10 +5,10 @@ export async function sendSystemNotification( title: string, body: string ): Promise { - if (!document.hidden && document.hasFocus()) return if (isDesktop()) { await getTransport().call("send_notification", { title, body }) } else { + if (!document.hidden && document.hasFocus()) return // Web fallback: Browser Notification API if (Notification.permission === "granted") { new Notification(title, { body }) From 88c48dcb9af7fd12494aea081955b62f8153ba0f Mon Sep 17 00:00:00 2001 From: Terry <932914698@qq.com> Date: Wed, 12 Aug 2026 15:42:02 +0800 Subject: [PATCH 3/4] fix(notifications): focus conversation on toast click --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/notification.rs | 44 ++++++++++- src-tauri/src/lib.rs | 8 +- src/contexts/acp-connections-context.test.tsx | 3 +- src/contexts/acp-connections-context.tsx | 73 ++++++++++++------- src/lib/notification.test.ts | 13 ++++ src/lib/notification.ts | 11 ++- 8 files changed, 123 insertions(+), 31 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a12ea1ab..952d3a225 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1076,6 +1076,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-winrt-notification", "temp-env", "tempfile", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0a89204f1..1ba6f1237 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -148,6 +148,7 @@ mac-notification-sys = "0.6" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Threading"] } junction = "1" +tauri-winrt-notification = "0.7.2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/commands/notification.rs b/src-tauri/src/commands/notification.rs index 7b335e3e8..2a657934c 100644 --- a/src-tauri/src/commands/notification.rs +++ b/src-tauri/src/commands/notification.rs @@ -1,14 +1,16 @@ #[cfg(feature = "tauri-runtime")] use tauri::AppHandle; +use serde::Deserialize; use crate::app_error::AppCommandError; #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn send_notification( - #[allow(unused_variables)] app: AppHandle, + app: AppHandle, title: String, body: String, + target: Option, ) -> Result<(), AppCommandError> { #[cfg(target_os = "macos")] { @@ -25,7 +27,36 @@ pub async fn send_notification( .send(); } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + use tauri::Emitter; + use tauri_winrt_notification::Toast; + + let app_for_click = app.clone(); + let target_for_click = target.clone(); + Toast::new("app.codeg") + .title(&title) + .text1(&body) + .on_activated(move |_| { + crate::commands::windows::show_main_window(&app_for_click); + if let Some(target) = target_for_click.as_ref() { + let _ = app_for_click.emit_to( + "main", + "workspace://focus-conversation", + serde_json::json!({ + "folderId": target.folder_id, + "conversationId": target.conversation_id, + "agent": target.agent, + }), + ); + } + Ok(()) + }) + .show() + .map_err(|error| AppCommandError::window("Failed to show notification", error.to_string()))?; + } + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] { use tauri_plugin_notification::NotificationExt; let _ = app.notification().builder().title(title).body(body).show(); @@ -33,3 +64,12 @@ pub async fn send_notification( Ok(()) } + +#[derive(Clone, Debug, Deserialize)] +pub struct NotificationTarget { + #[serde(rename = "folderId")] + pub folder_id: i32, + #[serde(rename = "conversationId")] + pub conversation_id: i32, + pub agent: String, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4d27b28e..883965003 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,7 +111,13 @@ mod tauri_app { ); tauri::async_runtime::spawn(async move { let _ = - notification::send_notification(app, "Codeg Web service".to_string(), body).await; + notification::send_notification( + app, + "Codeg Web service".to_string(), + body, + None, + ) + .await; }); } diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index 8d219cd37..b79d326d1 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -1021,7 +1021,8 @@ describe("out-of-turn wire guard + background activity", () => { expect(h.sendSystemNotification).toHaveBeenCalledWith( "x - Claude Code questionDialog.title", - "Should the notification include the final result?" + "Should the notification include the final result?", + null ) }) diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index c5e6e2221..763b0f964 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -203,6 +203,32 @@ function lastAssistantText(liveMessage: LiveMessage | null): string | null { return notificationSummary(text) } +function notificationTarget(connection: ConnectionState) { + const indexedConversationId = connection.sessionId + ? getConversationIdByExternalIdFromStore(connection.sessionId) + : null + const contextConversationId = Number( + /-(\d+)$/.exec(connection.contextKey)?.[1] + ) + const conversationId = + indexedConversationId ?? + (Number.isSafeInteger(contextConversationId) ? contextConversationId : null) + const tab = useTabStore + .getState() + .tabs.find( + (candidate) => + candidate.conversationId === conversationId || + candidate.runtimeConversationId === conversationId + ) + return tab?.conversationId != null + ? { + folderId: tab.folderId, + conversationId: tab.conversationId, + agent: connection.agentType, + } + : null +} + // ── Per-connection state ── export interface ConnectionState { @@ -2636,24 +2662,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const notificationTitle = useCallback( (connection: ConnectionState, status: string): string => { - const indexedConversationId = connection.sessionId - ? getConversationIdByExternalIdFromStore(connection.sessionId) + const target = notificationTarget(connection) + const tab = target + ? useTabStore + .getState() + .tabs.find( + (candidate) => candidate.conversationId === target.conversationId + ) : null - const contextConversationId = Number( - /-(\d+)$/.exec(connection.contextKey)?.[1] - ) - const conversationId = - indexedConversationId ?? - (Number.isSafeInteger(contextConversationId) - ? contextConversationId - : null) - const tab = useTabStore - .getState() - .tabs.find( - (candidate) => - candidate.conversationId === conversationId || - candidate.runtimeConversationId === conversationId - ) const workspace = (tab ? useAppWorkspaceStore @@ -3225,7 +3241,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { "questionDialog.title" )}` ), - question + question, + notificationTarget(nc) ).catch(() => {}) } } @@ -3265,7 +3282,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { )}` ), notificationSummary(e.plan_markdown) ?? - tChat("planApproval.title") + tChat("planApproval.title"), + notificationTarget(nc) ).catch(() => {}) } } @@ -3349,9 +3367,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { tChat("backgroundTasks.settledFallback", { status: settled.status, }) - sendSystemNotification(title, `${agentLabel}: ${body}`).catch( - () => {} - ) + sendSystemNotification( + title, + `${agentLabel}: ${body}`, + nc ? notificationTarget(nc) : null + ).catch(() => {}) } // 4. flip each async sub-agent's launch card to its terminal // (completed + result) state IN-MEMORY, by rewriting the @@ -3406,7 +3426,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { nc, `${agentLabel} ${tChat("questionDialog.title")}` ), - tChat("permissionDialog.subtitle") + tChat("permissionDialog.subtitle"), + notificationTarget(nc) ).catch(() => {}) } } @@ -3612,7 +3633,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { notificationTitle(nc, status), notificationSummary(pendingQuestionText) ?? lastAssistantText(turnConn?.liveMessage ?? null) ?? - t(key, { agent: agentLabel }) + t(key, { agent: agentLabel }), + notificationTarget(nc) ).catch(() => {}) } } @@ -3727,7 +3749,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { t("notificationError", { agent: agentLabel, message: localizedMessage, - }) + }), + notificationTarget(nc) ).catch(() => {}) } break diff --git a/src/lib/notification.test.ts b/src/lib/notification.test.ts index b4403bf11..c7a487d0c 100644 --- a/src/lib/notification.test.ts +++ b/src/lib/notification.test.ts @@ -30,6 +30,19 @@ describe("sendSystemNotification", () => { expect(h.call).toHaveBeenCalledWith("send_notification", { title: "Codeg", body: "done", + target: undefined, + }) + }) + + it("passes the conversation target to the native notification", async () => { + const target = { folderId: 7, conversationId: 42, agent: "codex" } + + await sendSystemNotification("Codeg", "done", target) + + expect(h.call).toHaveBeenCalledWith("send_notification", { + title: "Codeg", + body: "done", + target, }) }) diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 25b05a8e3..972afc2bb 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,12 +1,19 @@ import { getTransport } from "./transport" import { isDesktop } from "./transport" +export interface NotificationTarget { + folderId: number + conversationId: number + agent: string +} + export async function sendSystemNotification( title: string, - body: string + body: string, + target?: NotificationTarget | null ): Promise { if (isDesktop()) { - await getTransport().call("send_notification", { title, body }) + await getTransport().call("send_notification", { title, body, target }) } else { if (!document.hidden && document.hasFocus()) return // Web fallback: Browser Notification API From 2ba5a50434c7c3882681c054aac1704ded9a5df8 Mon Sep 17 00:00:00 2001 From: Terry <932914698@qq.com> Date: Wed, 12 Aug 2026 16:49:46 +0800 Subject: [PATCH 4/4] feat(notifications): add notification preferences --- .../notification-sound-settings.test.tsx | 33 +- .../settings/notification-sound-settings.tsx | 313 ++++++++++-------- src/i18n/messages/ar.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/lib/notification-sound-prefs.ts | 14 + src/lib/notification.test.ts | 59 +++- src/lib/notification.ts | 12 +- 15 files changed, 334 insertions(+), 137 deletions(-) diff --git a/src/components/settings/notification-sound-settings.test.tsx b/src/components/settings/notification-sound-settings.test.tsx index a0817458e..ef9725c41 100644 --- a/src/components/settings/notification-sound-settings.test.tsx +++ b/src/components/settings/notification-sound-settings.test.tsx @@ -43,11 +43,42 @@ describe("NotificationSoundSettingsSection", () => { ).not.toBeChecked() // With sounds off the section IS the master switch: the heading labels it, // so there is no second row (nor a card around one) saying so again. - expect(screen.getAllByRole("switch")).toHaveLength(1) + expect(screen.getAllByRole("switch")).toHaveLength(3) // The event catalogue is only meaningful once something can play. expect(screen.queryByText("Turn Complete")).not.toBeInTheDocument() }) + it("persists the system notification switches", () => { + renderSection() + + expect( + screen.getByRole("switch", { name: /system notifications/i }) + ).toBeChecked() + expect( + screen.getByRole("switch", { + name: /only when the Codeg window is not focused/i, + }) + ).toBeChecked() + fireEvent.click( + screen.getByRole("switch", { + name: /only when the Codeg window is not focused/i, + }) + ) + + expect( + loadNotificationSoundPrefs().systemNotificationsOnlyWhenUnfocused + ).toBe(false) + fireEvent.click( + screen.getByRole("switch", { name: /system notifications/i }) + ) + expect(loadNotificationSoundPrefs().systemNotificationsEnabled).toBe(false) + expect( + screen.queryByRole("switch", { + name: /only when the Codeg window is not focused/i, + }) + ).not.toBeInTheDocument() + }) + it("persists the master switch and reveals the event catalogue", () => { renderSection() diff --git a/src/components/settings/notification-sound-settings.tsx b/src/components/settings/notification-sound-settings.tsx index 2c4968bc2..e525cb4bd 100644 --- a/src/components/settings/notification-sound-settings.tsx +++ b/src/components/settings/notification-sound-settings.tsx @@ -18,6 +18,7 @@ import { useCallback } from "react" import { useTranslations } from "next-intl" import { + Bell, BellOff, ListMusic, Play, @@ -96,150 +97,194 @@ export function NotificationSoundSettingsSection() { const volumePercent = Math.round(prefs.volume * 100) return ( - // The master switch is the section's heading row: with sounds off the whole - // section is that one line, and the knobs it gates appear under it rather - // than in a card that repeats "Enable notification sounds". - - {t("description")} - {t("enableHint")} - - } - htmlFor="notification-sound-enabled" - control={ - - saveNotificationSoundPrefs({ ...prefs, enabled }) - } - /> - } - > - {/* The two knobs that shape every cue — one card, because volume and - "only when unfocused" are meaningless without the switch above. */} - {prefs.enabled && ( - - - {volumePercent}% - + <> + + saveNotificationSoundPrefs({ + ...prefs, + systemNotificationsEnabled, + }) } - > -
- - saveNotificationSoundPrefs({ - ...prefs, - volume: value / 100, - }) - } - /> - -
-
+ /> + } + > + {prefs.systemNotificationsEnabled && ( + + + saveNotificationSoundPrefs({ + ...prefs, + systemNotificationsOnlyWhenUnfocused, + }) + } + /> + } + /> + + )} +
- - saveNotificationSoundPrefs({ ...prefs, onlyWhenUnfocused }) - } - /> + {/* The master switch is the section's heading row: with sounds off the whole + section is that one line, and the knobs it gates appear under it rather + than in a card that repeats "Enable notification sounds". */} + + {t("description")} + {t("enableHint")} + + } + htmlFor="notification-sound-enabled" + control={ + + saveNotificationSoundPrefs({ ...prefs, enabled }) } /> - - )} + } + > + {/* The two knobs that shape every cue — one card, because volume and + "only when unfocused" are meaningless without the switch above. */} + {prefs.enabled && ( + + + {volumePercent}% + + } + > +
+ + saveNotificationSoundPrefs({ + ...prefs, + volume: value / 100, + }) + } + /> + +
+
- {prefs.enabled && ( - - {/* The per-event tones are one setting with many values, so they are + + saveNotificationSoundPrefs({ ...prefs, onlyWhenUnfocused }) + } + /> + } + /> + + )} + + {prefs.enabled && ( + + {/* The per-event tones are one setting with many values, so they are a single row whose control is the list — not one row per event, which would repeat the same explanation five times. */} - -
- {SOUND_EVENT_IDS.map((eventId) => { - const tone = prefs.tones[eventId] - const label = tEvents(EVENT_LABEL_KEYS[eventId]) - return ( -
- {label} -
- + setTone(eventId, value as SoundToneId) + } + > + {/* `size` rather than a bare `h-8`: the trigger's own height is gated on `data-size`, which outranks an ungated utility in the class list. */} - + + + + {SOUND_TONE_IDS.map((toneId) => ( + + {t(TONE_LABEL_KEYS[toneId])} + + ))} + + + + + +
-
- ) - })} - -
-
- )} -
+ ) + })} + +
+ + )} + + ) } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 4c90f410a..5652988a6 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4083,6 +4083,10 @@ "loadFailed": "فشل التحميل: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "إشعارات النظام", + "systemDescription": "عرض إشعار نظام عند اكتمال المهمة أو توقفها أو فشلها أو حاجتها إلى انتباهك. يؤدي النقر عليه إلى فتح المحادثة المطابقة في Codeg.", + "systemOnlyWhenUnfocused": "فقط عندما لا تكون نافذة Codeg نشطة", + "systemOnlyWhenUnfocusedHint": "عدم عرض إشعار مكرر أثناء استخدام Codeg.", "title": "أصوات التنبيه", "description": "تشغيل صوت قصير عند وقوع حدث للوكيل. هي نفس الأحداث التي تُرسل إلى قنوات المحادثة، وهذا الإعداد يخص هذا الجهاز فقط.", "enableHint": "معطّل افتراضيًا. تُشغَّل الأصوات في نافذة مساحة العمل بهذا المتصفح أو التطبيق فقط.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index a6a64b7ed..303a03a58 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4083,6 +4083,10 @@ "loadFailed": "Laden fehlgeschlagen: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "Systembenachrichtigungen", + "systemDescription": "Zeigt eine Systembenachrichtigung an, wenn eine Aufgabe abgeschlossen, abgebrochen oder fehlgeschlagen ist oder deine Aufmerksamkeit benötigt. Ein Klick öffnet die passende Unterhaltung in Codeg.", + "systemOnlyWhenUnfocused": "Nur wenn das Codeg-Fenster nicht fokussiert ist", + "systemOnlyWhenUnfocusedHint": "Keine zusätzliche Benachrichtigung anzeigen, während du Codeg ansiehst.", "title": "Benachrichtigungstöne", "description": "Spielt einen kurzen Ton ab, wenn ein Agent-Ereignis eintritt. Es sind dieselben Ereignisse, die an die Chat-Kanäle gesendet werden; diese Einstellung gilt nur für dieses Gerät.", "enableHint": "Standardmäßig aus. Töne werden nur im Arbeitsbereich-Fenster dieses Browsers oder dieser App abgespielt.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 589e1a0d5..32266fc1d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4083,6 +4083,10 @@ "loadFailed": "Failed to load: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "System notifications", + "systemDescription": "Show a system notification when a task finishes, stops, fails, or needs your attention. Clicking it opens Codeg at the matching conversation.", + "systemOnlyWhenUnfocused": "Only when the Codeg window is not focused", + "systemOnlyWhenUnfocusedHint": "Do not show a duplicate notification while you are viewing Codeg.", "title": "Notification sounds", "description": "Play a short sound when an agent event fires. These are the same events the chat channels push — configured here for this device only.", "enableHint": "Off by default. Sounds play in the workspace window of this browser or app only.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 312891670..74b9be83f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4083,6 +4083,10 @@ "loadFailed": "Error al cargar: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "Notificaciones del sistema", + "systemDescription": "Muestra una notificación del sistema cuando una tarea termina, se interrumpe, falla o necesita tu atención. Al hacer clic se abre la conversación correspondiente en Codeg.", + "systemOnlyWhenUnfocused": "Solo cuando la ventana de Codeg no está enfocada", + "systemOnlyWhenUnfocusedHint": "No mostrar una notificación duplicada mientras estás viendo Codeg.", "title": "Sonidos de notificación", "description": "Reproduce un sonido breve cuando se produce un evento del agente. Son los mismos eventos que se envían a los canales de chat; esta configuración solo se aplica a este dispositivo.", "enableHint": "Desactivado por defecto. Los sonidos solo suenan en la ventana del espacio de trabajo de este navegador o aplicación.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6b42fb3bf..66b9b4bac 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4083,6 +4083,10 @@ "loadFailed": "Échec du chargement : {detail}" }, "NotificationSoundSettings": { + "systemTitle": "Notifications système", + "systemDescription": "Affiche une notification système lorsqu’une tâche se termine, s’interrompt, échoue ou nécessite votre attention. Un clic ouvre la conversation correspondante dans Codeg.", + "systemOnlyWhenUnfocused": "Uniquement lorsque la fenêtre Codeg n’est pas au premier plan", + "systemOnlyWhenUnfocusedHint": "Ne pas afficher de notification en double pendant que vous consultez Codeg.", "title": "Sons de notification", "description": "Joue un son bref lorsqu’un événement d’agent se produit. Ce sont les mêmes événements que ceux envoyés aux canaux de discussion ; ce réglage ne s’applique qu’à cet appareil.", "enableHint": "Désactivé par défaut. Les sons ne sont joués que dans la fenêtre de l’espace de travail de ce navigateur ou de cette application.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 86552ee17..afe54fe50 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4083,6 +4083,10 @@ "loadFailed": "読み込みに失敗しました: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "システム通知", + "systemDescription": "タスクの完了、中断、エラー、または対応が必要なときにシステム通知を表示します。クリックすると Codeg の該当する会話が開きます。", + "systemOnlyWhenUnfocused": "Codeg ウィンドウが非アクティブなときのみ通知", + "systemOnlyWhenUnfocusedHint": "Codeg を表示中は重複した通知を表示しません。", "title": "通知音", "description": "エージェントのイベント発生時に短い通知音を鳴らします。対象はチャットチャンネルに送られるものと同じイベントで、この設定はこの端末にのみ適用されます。", "enableHint": "既定ではオフです。通知音はこのブラウザまたはアプリのワークスペースウィンドウでのみ再生されます。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 80323250a..36f5cf4f9 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4083,6 +4083,10 @@ "loadFailed": "불러오기 실패: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "시스템 알림", + "systemDescription": "작업이 완료, 중단, 실패하거나 확인이 필요할 때 시스템 알림을 표시합니다. 클릭하면 Codeg의 해당 대화가 열립니다.", + "systemOnlyWhenUnfocused": "Codeg 창이 비활성화된 경우에만 알림", + "systemOnlyWhenUnfocusedHint": "Codeg를 보고 있는 동안에는 중복 알림을 표시하지 않습니다.", "title": "알림음", "description": "에이전트 이벤트가 발생하면 짧은 알림음을 재생합니다. 채팅 채널로 전송되는 것과 같은 이벤트이며, 이 설정은 이 기기에만 적용됩니다.", "enableHint": "기본값은 꺼짐입니다. 알림음은 이 브라우저 또는 앱의 작업 공간 창에서만 재생됩니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c8139a66b..cfc8ca780 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4083,6 +4083,10 @@ "loadFailed": "Falha ao carregar: {detail}" }, "NotificationSoundSettings": { + "systemTitle": "Notificações do sistema", + "systemDescription": "Mostra uma notificação do sistema quando uma tarefa termina, é interrompida, falha ou precisa da sua atenção. Ao clicar, a conversa correspondente é aberta no Codeg.", + "systemOnlyWhenUnfocused": "Somente quando a janela do Codeg não estiver em foco", + "systemOnlyWhenUnfocusedHint": "Não mostrar uma notificação duplicada enquanto você estiver vendo o Codeg.", "title": "Sons de notificação", "description": "Reproduz um som curto quando ocorre um evento do agente. São os mesmos eventos enviados aos canais de chat; esta configuração vale apenas para este dispositivo.", "enableHint": "Desativado por padrão. Os sons tocam apenas na janela do espaço de trabalho deste navegador ou aplicativo.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 5b065fc52..acabf60a9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4083,6 +4083,10 @@ "loadFailed": "加载失败:{detail}" }, "NotificationSoundSettings": { + "systemTitle": "系统通知", + "systemDescription": "任务完成、中断、异常或需要你处理时显示系统弹窗。点击弹窗会打开 Codeg 并跳转到对应会话。", + "systemOnlyWhenUnfocused": "仅在 Codeg 窗口未聚焦时通知", + "systemOnlyWhenUnfocusedHint": "正在查看 Codeg 时不重复弹窗。", "title": "提示音", "description": "智能体事件触发时播放一段简短的提示音。这里的事件与消息渠道推送的完全一致,但设置只对本设备生效。", "enableHint": "默认关闭。提示音只在当前浏览器或应用的工作区窗口中播放。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index db543de31..ac6247310 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4083,6 +4083,10 @@ "loadFailed": "載入失敗:{detail}" }, "NotificationSoundSettings": { + "systemTitle": "系統通知", + "systemDescription": "任務完成、中斷、異常或需要你處理時顯示系統彈窗。點擊彈窗會開啟 Codeg 並跳轉到對應會話。", + "systemOnlyWhenUnfocused": "僅在 Codeg 視窗未聚焦時通知", + "systemOnlyWhenUnfocusedHint": "正在檢視 Codeg 時不重複彈窗。", "title": "提示音", "description": "智慧體事件觸發時播放一段簡短的提示音。這裡的事件與訊息通道推送的完全一致,但設定只對本裝置生效。", "enableHint": "預設關閉。提示音只在目前瀏覽器或應用程式的工作區視窗中播放。", diff --git a/src/lib/notification-sound-prefs.ts b/src/lib/notification-sound-prefs.ts index 5773836ca..fa490de28 100644 --- a/src/lib/notification-sound-prefs.ts +++ b/src/lib/notification-sound-prefs.ts @@ -48,6 +48,10 @@ export const SOUND_TONE_IDS = [ export type SoundToneId = (typeof SOUND_TONE_IDS)[number] export interface NotificationSoundPrefs { + /** Master switch for visual system notifications. */ + systemNotificationsEnabled: boolean + /** Only show system notifications while the workspace is unfocused. */ + systemNotificationsOnlyWhenUnfocused: boolean /** Master switch. Off until the user opts in — see DEFAULTS below. */ enabled: boolean /** 0..1, applied as the peak gain of every tone. */ @@ -70,6 +74,8 @@ export interface NotificationSoundPrefs { * is about not exporting prompt text off the machine.) */ export const DEFAULT_NOTIFICATION_SOUND_PREFS: NotificationSoundPrefs = { + systemNotificationsEnabled: true, + systemNotificationsOnlyWhenUnfocused: true, enabled: false, volume: 0.6, onlyWhenUnfocused: false, @@ -115,6 +121,14 @@ export function parseNotificationSoundPrefs( } return { + systemNotificationsEnabled: + typeof source.systemNotificationsEnabled === "boolean" + ? source.systemNotificationsEnabled + : defaults.systemNotificationsEnabled, + systemNotificationsOnlyWhenUnfocused: + typeof source.systemNotificationsOnlyWhenUnfocused === "boolean" + ? source.systemNotificationsOnlyWhenUnfocused + : defaults.systemNotificationsOnlyWhenUnfocused, enabled: typeof source.enabled === "boolean" ? source.enabled : defaults.enabled, // Clamp rather than reject: a slider that once wrote 1.2 should land on diff --git a/src/lib/notification.test.ts b/src/lib/notification.test.ts index c7a487d0c..6940a62e8 100644 --- a/src/lib/notification.test.ts +++ b/src/lib/notification.test.ts @@ -11,11 +11,18 @@ vi.mock("./transport", () => ({ })) import { sendSystemNotification } from "./notification" +import { + DEFAULT_NOTIFICATION_SOUND_PREFS, + resetNotificationSoundPrefsCacheForTests, + saveNotificationSoundPrefs, +} from "./notification-sound-prefs" describe("sendSystemNotification", () => { beforeEach(() => { h.call.mockClear() h.desktop = true + localStorage.clear() + resetNotificationSoundPrefsCacheForTests() vi.unstubAllGlobals() Object.defineProperty(document, "hidden", { configurable: true, @@ -24,7 +31,12 @@ describe("sendSystemNotification", () => { vi.spyOn(document, "hasFocus").mockReturnValue(true) }) - it("uses the native notification even while Codeg is focused", async () => { + it("uses the native notification while Codeg is focused when background-only mode is off", async () => { + saveNotificationSoundPrefs({ + ...DEFAULT_NOTIFICATION_SOUND_PREFS, + systemNotificationsOnlyWhenUnfocused: false, + }) + await sendSystemNotification("Codeg", "done") expect(h.call).toHaveBeenCalledWith("send_notification", { @@ -34,8 +46,49 @@ describe("sendSystemNotification", () => { }) }) + it("stays quiet when system notifications are disabled", async () => { + saveNotificationSoundPrefs({ + ...DEFAULT_NOTIFICATION_SOUND_PREFS, + systemNotificationsEnabled: false, + }) + + await sendSystemNotification("Codeg", "done") + + expect(h.call).not.toHaveBeenCalled() + }) + + it("stays quiet while focused when background-only notifications are enabled", async () => { + saveNotificationSoundPrefs({ + ...DEFAULT_NOTIFICATION_SOUND_PREFS, + systemNotificationsOnlyWhenUnfocused: true, + }) + + await sendSystemNotification("Codeg", "done") + + expect(h.call).not.toHaveBeenCalled() + }) + + it("uses the native notification while hidden in background-only mode", async () => { + saveNotificationSoundPrefs({ + ...DEFAULT_NOTIFICATION_SOUND_PREFS, + systemNotificationsOnlyWhenUnfocused: true, + }) + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) + + await sendSystemNotification("Codeg", "done") + + expect(h.call).toHaveBeenCalledOnce() + }) + it("passes the conversation target to the native notification", async () => { const target = { folderId: 7, conversationId: 42, agent: "codex" } + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) await sendSystemNotification("Codeg", "done", target) @@ -48,6 +101,10 @@ describe("sendSystemNotification", () => { it("stays quiet in a focused web browser", async () => { h.desktop = false + saveNotificationSoundPrefs({ + ...DEFAULT_NOTIFICATION_SOUND_PREFS, + systemNotificationsOnlyWhenUnfocused: true, + }) await sendSystemNotification("Codeg", "done") diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 972afc2bb..673747c24 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,5 +1,6 @@ import { getTransport } from "./transport" import { isDesktop } from "./transport" +import { getNotificationSoundPrefs } from "./notification-sound-prefs" export interface NotificationTarget { folderId: number @@ -12,10 +13,19 @@ export async function sendSystemNotification( body: string, target?: NotificationTarget | null ): Promise { + const prefs = getNotificationSoundPrefs() + if (!prefs.systemNotificationsEnabled) return + if ( + prefs.systemNotificationsOnlyWhenUnfocused && + !document.hidden && + document.hasFocus() + ) { + return + } + if (isDesktop()) { await getTransport().call("send_notification", { title, body, target }) } else { - if (!document.hidden && document.hasFocus()) return // Web fallback: Browser Notification API if (Notification.permission === "granted") { new Notification(title, { body })