From 22ba91e0dc3df0eb78b3addd537ee2d6135ac1bf Mon Sep 17 00:00:00 2001 From: xCss <10877162+xCss@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:06:16 +0800 Subject: [PATCH 1/2] feat: browser notification when agent session ends Closes #339. Ref discussion #317. When the tab is in the background, fires a Web Notifications API popup on agent completion. The notification title uses the current session name (falls back to i18n "Session complete"); the body is the i18n "Task finished." string, so both en and zh-CN are covered. Permission is requested lazily on the first background completion; denied or unsupported browsers are silently skipped. --- components/AppShell.tsx | 16 +++++++++++++++- lib/i18n/messages/en.ts | 2 ++ lib/i18n/messages/zh-CN.ts | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d4d827fa7..f31c2e705 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -413,7 +413,21 @@ export function AppShell() { const handleAgentEnd = useCallback(() => { setRefreshKey((k) => k + 1); setExplorerRefreshKey((k) => k + 1); - }, []); + + if (document.visibilityState === "visible") return; + if (!("Notification" in window)) return; + + const fire = () => { + const title = selectedSession?.name ?? translate("i18n.sessionComplete"); + new Notification(title, { body: translate("i18n.taskFinished") }); + }; + + if (Notification.permission === "granted") { + fire(); + } else if (Notification.permission === "default") { + void Notification.requestPermission().then((p) => { if (p === "granted") fire(); }); + } + }, [selectedSession, translate]); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..c9508af18 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -433,5 +433,7 @@ export const enLocale: LocalePlugin = { "i18n.thinkingUnavailable": "Thinking content unavailable", "i18n.before": "Before", "i18n.after": "After", + "i18n.sessionComplete": "Session complete", + "i18n.taskFinished": "Task finished.", }, }; diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..745272ad4 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -433,5 +433,7 @@ export const zhCNLocale: LocalePlugin = { "i18n.thinkingUnavailable": "思考内容不可用", "i18n.before": "之前", "i18n.after": "之后", + "i18n.sessionComplete": "任务完成", + "i18n.taskFinished": "任务已完成。", }, }; From 0aa23cfc082569a02d28e83a07159ec8c0a9a042 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 7 Aug 2026 15:57:17 +0800 Subject: [PATCH 2/2] feat: focus session from completion notifications --- components/AppShell.tsx | 15 ++++- lib/browser-notifications.test.mjs | 91 ++++++++++++++++++++++++++++++ lib/browser-notifications.ts | 63 +++++++++++++++++++++ public/sw.js | 42 ++++++++++++++ public/sw.test.mjs | 86 ++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 lib/browser-notifications.test.mjs create mode 100644 lib/browser-notifications.ts create mode 100644 public/sw.test.mjs diff --git a/components/AppShell.tsx b/components/AppShell.tsx index f31c2e705..3588c70e6 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -20,6 +20,7 @@ import { useResizablePanel } from "@/hooks/useResizablePanel"; import { copyText } from "@/lib/clipboard"; import { getFileName } from "@/lib/file-paths"; import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy"; +import { showCompletionNotification } from "@/lib/browser-notifications"; import { getInitialNavigation } from "@/lib/initial-navigation"; import { getDefaultRightPanelWidth, @@ -417,9 +418,19 @@ export function AppShell() { if (document.visibilityState === "visible") return; if (!("Notification" in window)) return; + const targetSession = selectedSession; const fire = () => { const title = selectedSession?.name ?? translate("i18n.sessionComplete"); - new Notification(title, { body: translate("i18n.taskFinished") }); + const sessionUrl = targetSession ? `/?session=${encodeURIComponent(targetSession.id)}` : "/"; + void showCompletionNotification({ + title, + body: translate("i18n.taskFinished"), + sessionUrl, + onClick: () => { + window.focus(); + if (targetSession) handleSelectSession(targetSession); + }, + }); }; if (Notification.permission === "granted") { @@ -427,7 +438,7 @@ export function AppShell() { } else if (Notification.permission === "default") { void Notification.requestPermission().then((p) => { if (p === "granted") fire(); }); } - }, [selectedSession, translate]); + }, [handleSelectSession, selectedSession, translate]); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; diff --git a/lib/browser-notifications.test.mjs b/lib/browser-notifications.test.mjs new file mode 100644 index 000000000..122b1fc44 --- /dev/null +++ b/lib/browser-notifications.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadSubject() { + return import("./browser-notifications.ts"); +} + +test("uses a service worker notification when a registration is available", async () => { + const { showCompletionNotification } = await loadSubject(); + const shown = []; + let constructorCalled = false; + + const delivery = await showCompletionNotification({ + title: "Session complete", + body: "Task finished.", + sessionUrl: "/?session=session-1", + onClick: () => assert.fail("service worker owns the click handler"), + }, { + createWindowNotification: () => { + constructorCalled = true; + throw new Error("unexpected constructor call"); + }, + getServiceWorkerRegistration: async () => ({ + showNotification: async (title, options) => shown.push({ title, options }), + }), + }); + + assert.equal(delivery, "service-worker"); + assert.equal(constructorCalled, false); + assert.deepEqual(shown, [{ + title: "Session complete", + options: { + body: "Task finished.", + data: { url: "/?session=session-1" }, + }, + }]); +}); + +test("falls back to a page notification and wires its click handler", async () => { + const { showCompletionNotification } = await loadSubject(); + let notificationOptions; + let clicked = false; + let closed = false; + const notification = { + onclick: null, + close: () => { closed = true; }, + }; + + const delivery = await showCompletionNotification({ + title: "Session complete", + body: "Task finished.", + sessionUrl: "/?session=session-1", + onClick: () => { clicked = true; }, + }, { + createWindowNotification: (title, options) => { + notificationOptions = { title, options }; + return notification; + }, + getServiceWorkerRegistration: async () => { + throw new Error("service worker unavailable"); + }, + }); + + assert.equal(delivery, "window"); + assert.deepEqual(notificationOptions, { + title: "Session complete", + options: { body: "Task finished." }, + }); + + notification.onclick(); + assert.equal(closed, true); + assert.equal(clicked, true); +}); + +test("silently skips notification when neither delivery mechanism works", async () => { + const { showCompletionNotification } = await loadSubject(); + + const delivery = await showCompletionNotification({ + title: "Session complete", + body: "Task finished.", + sessionUrl: "/", + onClick: () => {}, + }, { + createWindowNotification: () => { + throw new TypeError("Illegal constructor"); + }, + getServiceWorkerRegistration: null, + }); + + assert.equal(delivery, null); +}); diff --git a/lib/browser-notifications.ts b/lib/browser-notifications.ts new file mode 100644 index 000000000..0ee03c5e7 --- /dev/null +++ b/lib/browser-notifications.ts @@ -0,0 +1,63 @@ +interface WindowNotificationLike { + onclick: Notification["onclick"]; + close: () => void; +} + +interface ServiceWorkerRegistrationLike { + showNotification: (title: string, options?: NotificationOptions) => Promise; +} + +export interface CompletionNotificationEnvironment { + createWindowNotification: (title: string, options?: NotificationOptions) => WindowNotificationLike; + getServiceWorkerRegistration: (() => Promise) | null; +} + +interface CompletionNotificationOptions { + title: string; + body: string; + sessionUrl: string; + onClick: () => void; +} + +export type NotificationDelivery = "service-worker" | "window" | null; + +function getBrowserEnvironment(): CompletionNotificationEnvironment { + return { + createWindowNotification: (title, options) => new Notification(title, options), + getServiceWorkerRegistration: "serviceWorker" in navigator + ? () => navigator.serviceWorker.getRegistration() + : null, + }; +} + +export async function showCompletionNotification( + options: CompletionNotificationOptions, + environment: CompletionNotificationEnvironment = getBrowserEnvironment(), +): Promise { + if (environment.getServiceWorkerRegistration) { + try { + const registration = await environment.getServiceWorkerRegistration(); + if (registration) { + await registration.showNotification(options.title, { + body: options.body, + data: { url: options.sessionUrl }, + }); + return "service-worker"; + } + } catch { + // Fall back to a page notification where the constructor is supported. + } + } + + try { + const notification = environment.createWindowNotification(options.title, { body: options.body }); + notification.onclick = () => { + notification.close(); + options.onClick(); + }; + return "window"; + } catch { + // Most mobile browsers expose Notification but require service-worker delivery. + return null; + } +} diff --git a/public/sw.js b/public/sw.js index 89ff2423a..ece9da5d6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -63,6 +63,48 @@ self.addEventListener("fetch", (event) => { } }); +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const requestedUrl = typeof event.notification.data?.url === "string" + ? event.notification.data.url + : "/"; + let targetUrl = new URL("/", self.location.origin); + try { + const candidate = new URL(requestedUrl, self.location.origin); + if (candidate.origin === self.location.origin) targetUrl = candidate; + } catch { + // Keep the root URL when notification data is malformed. + } + + event.waitUntil(focusOrOpenWindow(targetUrl.href)); +}); + +async function focusOrOpenWindow(targetUrl) { + const windowClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const exactClient = windowClients.find((client) => client.url === targetUrl); + const candidates = exactClient + ? [exactClient, ...windowClients.filter((client) => client !== exactClient)] + : windowClients; + + for (const client of candidates) { + try { + const targetClient = client.url === targetUrl + ? client + : (await client.navigate(targetUrl)) ?? client; + await targetClient.focus(); + return; + } catch { + // The window may have closed between matchAll and focus; try the next one. + } + } + + await self.clients.openWindow(targetUrl); +} + async function cacheFirst(request) { const cached = await caches.match(request); if (cached) return cached; diff --git a/public/sw.test.mjs b/public/sw.test.mjs new file mode 100644 index 000000000..0071a6027 --- /dev/null +++ b/public/sw.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const listeners = new Map(); +globalThis.self = { + location: { + href: "https://pi.test/sw.js?v=test", + origin: "https://pi.test", + }, + addEventListener: (type, listener) => listeners.set(type, listener), + clients: null, +}; + +await import("./sw.js"); + +function dispatchNotificationClick(data) { + let pending; + let closed = false; + listeners.get("notificationclick")({ + notification: { + data, + close: () => { closed = true; }, + }, + waitUntil: (promise) => { pending = promise; }, + }); + return { pending, wasClosed: () => closed }; +} + +test("notification click focuses an existing client at the session URL", async () => { + const calls = []; + const focusedClient = { + url: "https://pi.test/?session=session-1", + focus: async () => { calls.push("focus"); }, + navigate: async () => assert.fail("exact client should not navigate"), + }; + self.clients = { + matchAll: async () => [focusedClient], + openWindow: async () => assert.fail("existing client should be reused"), + }; + + const event = dispatchNotificationClick({ url: "/?session=session-1" }); + await event.pending; + + assert.equal(event.wasClosed(), true); + assert.deepEqual(calls, ["focus"]); +}); + +test("notification click navigates an existing client to the session", async () => { + const calls = []; + const navigatedClient = { + focus: async () => { calls.push("focus"); }, + }; + const existingClient = { + url: "https://pi.test/?session=other-session", + navigate: async (url) => { + calls.push(["navigate", url]); + return navigatedClient; + }, + focus: async () => assert.fail("the navigated client should be focused"), + }; + self.clients = { + matchAll: async () => [existingClient], + openWindow: async () => assert.fail("existing client should be reused"), + }; + + const event = dispatchNotificationClick({ url: "/?session=session-1" }); + await event.pending; + + assert.deepEqual(calls, [ + ["navigate", "https://pi.test/?session=session-1"], + "focus", + ]); +}); + +test("notification click opens a window and rejects cross-origin targets", async () => { + const opened = []; + self.clients = { + matchAll: async () => [], + openWindow: async (url) => { opened.push(url); }, + }; + + const event = dispatchNotificationClick({ url: "https://example.com/redirect" }); + await event.pending; + + assert.deepEqual(opened, ["https://pi.test/"]); +});