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
27 changes: 26 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -413,7 +414,31 @@ 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 targetSession = selectedSession;
const fire = () => {
const title = selectedSession?.name ?? translate("i18n.sessionComplete");
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") {
fire();
} else if (Notification.permission === "default") {
void Notification.requestPermission().then((p) => { if (p === "granted") fire(); });
}
}, [handleSelectSession, selectedSession, translate]);

const handleAutoName = useCallback(async () => {
const sessionId = selectedSession?.id;
Expand Down
91 changes: 91 additions & 0 deletions lib/browser-notifications.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
63 changes: 63 additions & 0 deletions lib/browser-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
interface WindowNotificationLike {
onclick: Notification["onclick"];
close: () => void;
}

interface ServiceWorkerRegistrationLike {
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
}

export interface CompletionNotificationEnvironment {
createWindowNotification: (title: string, options?: NotificationOptions) => WindowNotificationLike;
getServiceWorkerRegistration: (() => Promise<ServiceWorkerRegistrationLike | undefined>) | 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<NotificationDelivery> {
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;
}
}
2 changes: 2 additions & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
};
2 changes: 2 additions & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,5 +433,7 @@ export const zhCNLocale: LocalePlugin = {
"i18n.thinkingUnavailable": "思考内容不可用",
"i18n.before": "之前",
"i18n.after": "之后",
"i18n.sessionComplete": "任务完成",
"i18n.taskFinished": "任务已完成。",
},
};
42 changes: 42 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions public/sw.test.mjs
Original file line number Diff line number Diff line change
@@ -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/"]);
});