diff --git a/docs/phone.md b/docs/phone.md index 9654fa977..d28321c58 100644 --- a/docs/phone.md +++ b/docs/phone.md @@ -556,11 +556,21 @@ group sent through this one carries that machine's id: the worker opens once the group has connected it. An event makes one notification on a device. iOS shows every push and does not -let a tag replace a notification the page shows, so a page whose own core -pushes to it stays quiet: `pushCovers` in `lib/notify.ts` requires the page's -push switch, the granted permission and a live browser subscription. A device -without push, and a page's notices about another machine, keep the local -notification, through the service worker and the per-thread tag a push would use. +let a tag replace a notification the page shows, so a page stays quiet when a +push brings the same notice: its own core pushes to it, or the notice is about +a member of the group, which forwards it to the device's push home. +`pushCovers` in `lib/notify.ts` requires the page's push switch, the granted +permission and a live browser subscription. Otherwise the page shows the +notice through the service worker whenever one is registered, since Android +Chrome refuses `new Notification` on a page and the phone app's page serves no +machine. It uses the tag and data a push about that thread would: `thread-` +for this page's own core, `thread--` with `{threadId, core}` for a +machine of the group (`GroupLinks.coreOf`, handed to `lib/notify.ts` by the +workspace), so a later push replaces it and a tap opens the thread there. A +machine with no group id gets a `key` in the data, which the open window reads +through `openNotification`, and which the worker puts in the address it opens +(`?thread=&key=`) when no window is open or one refuses focus. Only a page without a worker builds the +notification itself. Routine turn notifications wait while someone watches their conversation. Questions and permission requests notify immediately, including asynchronous @@ -604,6 +614,27 @@ for those labels in the language it speaks to the worker's `boite-notify` cache, again on each language change; the worker shows them in place of the English body, which stays the fallback until the page has run once. +### Choosing which notifications + +Each device chooses the kinds it gets, all on by default: Approvals +(permission requests, plan approvals, settings and agent settings cards asking +to apply a change), Questions (an agent's questions, `boite ask`, a delegated +agent's), Finished and Errors. The rows sit under the notifications switch, in +General on the desktop and in App & notifications on the phone, and dim while +the switch is off. The choice lives in the page's `localStorage`: the kinds +turned off under `boite.notifications.muted`, so a kind added later starts on. +Local toasts read it (`shouldNotify` in `lib/notify.ts`). For Web Push the core +sends, so a subscribed device hands it over: `push.subscribe` takes `kinds`, +`push.kinds` changes them without subscribing again, `push.status` returns +them, and the switch off sends an empty list. The page records which machine +holds the subscription (`boite.web-push.home`, its address in this page), so a +change made while another machine is shown still goes there; the kinds are one +state for every machine of the window. A subscription stored without +kinds gets every kind. Every push carries its `kind` beside the `label`; a push +forwarded through the group carries it too, and the device's home machine +filters it by that subscription's kinds. The test notification has no kind and +always goes. + Disabling removes the server subscription and unsubscribes the browser. Revocation deletes the subscription with the pairing. Push services returning 404 or 410 retire the destination. Other delivery failures leave it subscribed @@ -625,7 +656,7 @@ one arrives. The path is: the installed app's Enable notifications asks the permission from the tap, subscribes with the core's VAPID key, and stores the subscription with `push.subscribe` against its pairing. A notification's payload is -`{title, body, threadId, tag, label, badge}` (`PushPayload` in the contracts), +`{title, body, threadId, tag, label, kind, badge}` (`PushPayload` in the contracts), plus `core` when another machine of the group sent it through this core; tapping it focuses an open window and posts it the thread, or, if the page refuses focus or none is open, opens `/?thread=` on the worker's own diff --git a/packages/contracts/src/access.ts b/packages/contracts/src/access.ts index 055ed7b9d..19e64c4eb 100644 --- a/packages/contracts/src/access.ts +++ b/packages/contracts/src/access.ts @@ -50,6 +50,8 @@ export const DEVICE_METHODS: ReadonlySet = new Set 'push.subscribe', 'push.unsubscribe', 'push.test', + // Which kinds of notification its own subscription receives: the device's choice, about nothing else. + 'push.kinds', // The sidebar and the composer's `@`. 'projects.list', // The logo the list announces: a bounded image already stored in the journal, no path and no disk read. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 82a7c49c1..0a2fbf103 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -97,7 +97,7 @@ export { PREVIEW_REFERENCES_PER_TURN, previewReferencesError, previewPrompt } fr export { previewToolOutputs, previewToolPart, inputPreview, longerThan, TOOL_OUTPUT_INLINE_CHARS, TOOL_OUTPUT_PREVIEW_CHARS, TOOL_OUTPUT_FOLDED_CHARS, TOOL_INPUT_INLINE_CHARS, TOOL_INPUT_PREVIEW_CHARS, TOOL_DOCUMENTS_INLINE_CHARS } from './message-preview'; export { forTransport, projectLiveMessage, projectLivePart, projectMessage, MESSAGE_SENT_MAX_BYTES, type TransportOptions } from './transport.ts'; export { boundedMessageWindow } from './message-window.ts'; -export { lastAgentText, notificationExcerpt, requestExcerpt, NOTIFICATION_TEXT_CHARS } from './notification-text'; +export { lastAgentText, notificationExcerpt, requestExcerpt, requestKind, NOTIFICATION_TEXT_CHARS } from './notification-text'; export { nativeAgentsOfTool, collectNativeAgents } from './native-agents.ts'; export { collectProcessAgents, processAgentCommand } from './process-agents.ts'; diff --git a/packages/contracts/src/messages.ts b/packages/contracts/src/messages.ts index e7070fc6e..545251a65 100644 --- a/packages/contracts/src/messages.ts +++ b/packages/contracts/src/messages.ts @@ -229,6 +229,13 @@ export const INITIAL_MESSAGE_PAGE = 40; * page left it, and falls back on the English body. */ export type NotificationLabel = 'done' | 'failed' | 'needsYou' | 'connected'; +/** + * What a notification is about, the unit a device turns on or off: a + * permission or a change to approve, a question, a finished turn, a failed one. + */ +export type NotificationKind = 'approvals' | 'questions' | 'finished' | 'errors'; +/** Every kind, in the order the settings list them. A device that never chose gets them all. */ +export const NOTIFICATION_KINDS: readonly NotificationKind[] = ['approvals', 'questions', 'finished', 'errors']; /** A Web Push payload, as the core sends it and the service worker reads it. */ export interface PushPayload { title: string; @@ -236,6 +243,8 @@ export interface PushPayload { threadId: string | null; tag: string; label?: NotificationLabel; + /** What the notification is about; the test notification has none. */ + kind?: NotificationKind; /** The app icon's count after this notification. */ badge?: number; /** @@ -303,8 +312,12 @@ export interface MessagesRpcMethods { 'artifacts.preview': { params: { threadId: ThreadId; path: string }; result: { url: string; shown: boolean } }; 'artifacts.previewClose': { params: { threadId: ThreadId; path: string }; result: { ok: true } }; /** Push credentials are owned by the authenticated pairing, never a caller-supplied session id. */ - 'push.status': { params: Record; result: { publicKey: string; subscribed: boolean } }; - 'push.subscribe': { params: { endpoint: string; keys: { p256dh: string; auth: string } }; result: { ok: true } }; + /** `kinds` is what this device's subscription receives, every kind when it never chose. */ + 'push.status': { params: Record; result: { publicKey: string; subscribed: boolean; kinds?: NotificationKind[] } }; + /** Without `kinds`, the subscription receives every kind. */ + 'push.subscribe': { params: { endpoint: string; keys: { p256dh: string; auth: string }; kinds?: NotificationKind[] }; result: { ok: true } }; + /** The kinds this device's subscription receives from now on, without subscribing again. An empty list receives nothing. */ + 'push.kinds': { params: { kinds: NotificationKind[] }; result: { ok: true } }; 'push.unsubscribe': { params: Record; result: { ok: true } }; 'push.test': { params: Record; result: { ok: true } }; /** Reserve the final workspace and initialize a native session without publishing a conversation or sending text. */ diff --git a/packages/contracts/src/notification-text.ts b/packages/contracts/src/notification-text.ts index fa3d50965..7bfe5824e 100644 --- a/packages/contracts/src/notification-text.ts +++ b/packages/contracts/src/notification-text.ts @@ -1,4 +1,4 @@ -import type { Message, PermissionRequest, QuestionRequest } from './index'; +import type { Message, NotificationKind, PermissionRequest, QuestionRequest } from './index'; /** About two lines on a phone's lock screen. */ export const NOTIFICATION_TEXT_CHARS = 140; @@ -77,3 +77,13 @@ export function requestExcerpt(request: Pick | Pick input[key]).find(value => typeof value === 'string' && value.trim()); return notificationExcerpt(target ? `${request.toolName}: ${target as string}` : request.toolName); } + +/** + * What a request for input is, for the device that chose which notifications + * it gets: a permission, or a card asking to apply a change (a setting, an + * agent's setting), is an approval; anything else the agent asks is a question. + */ +export function requestKind(request: Pick | Pick): NotificationKind { + if ('toolName' in request) return 'approvals'; + return request.agentSetting !== undefined || request.setting?.kind === 'change' ? 'approvals' : 'questions'; +} diff --git a/packages/core/src/access.ts b/packages/core/src/access.ts index 1b099e7a2..5bb1c5a1d 100644 --- a/packages/core/src/access.ts +++ b/packages/core/src/access.ts @@ -30,6 +30,8 @@ * quotas.list and subscriptionProxy.quotas let a phone draw the same limit bars * as the owner without a dashboard embed; a forced refresh reaches the * providers and stays the owner's, as do monitoring switches and resets. + * push.kinds lets a paired device choose which kinds of notification its own + * subscription receives; it reaches no other device's destination. */ import { AGENT_EVENTS, DEVICE_EVENTS, DEVICE_METHODS, type RpcEventName, type RpcMethodName } from '@boite/contracts'; diff --git a/packages/core/src/push.ts b/packages/core/src/push.ts index 121f36eb2..93bcc3908 100644 --- a/packages/core/src/push.ts +++ b/packages/core/src/push.ts @@ -1,6 +1,6 @@ import { createHash, ECDH, randomUUID } from 'node:crypto'; -import { lastAgentText, notifiesOnFinish, requestExcerpt } from '@boite/contracts'; -import type { CoordinationPeer, NotificationLabel, PushPayload, RpcEvents, RpcParams } from '@boite/contracts'; +import { lastAgentText, NOTIFICATION_KINDS, notifiesOnFinish, requestExcerpt, requestKind } from '@boite/contracts'; +import type { CoordinationPeer, NotificationKind, NotificationLabel, PushPayload, RpcEvents, RpcParams } from '@boite/contracts'; import type { PushSubscription } from 'web-push'; import type { Core } from './core.ts'; import { invalidParams, refused } from './errors.ts'; @@ -8,7 +8,10 @@ import type { AttentionReport } from './threads/focus.ts'; import { deviceElsewhere, ownDevice } from './group/devices.ts'; import { backgroundWaitOf } from './threads/records.ts'; +/** A device's destination; without `kinds`, written before devices chose, it receives every kind. */ type Subscription = RpcParams<'push.subscribe'>; +/** What a push says before it has a title and a badge. */ +type News = Pick & { kind: NotificationKind }; type Keys = { publicKey: string; privateKey: string }; const SUBSCRIPTIONS = 'web-push.subscriptions'; const KEYS = 'web-push.keys'; @@ -40,6 +43,9 @@ function readForwarded(value: unknown): Forwarded { if (label !== undefined && !LABELS.includes(label as NotificationLabel)) throw bad('push.label'); const badge = push['badge']; if (badge !== undefined && (!Number.isSafeInteger(badge) || (badge as number) < 0 || (badge as number) > 100_000)) throw bad('push.badge'); + // A member older than kinds sends none: its pushes go to every subscription, as they did. + const kind = push['kind']; + if (kind !== undefined && !NOTIFICATION_KINDS.includes(kind as NotificationKind)) throw bad('push.kind'); if (typeof id !== 'string' || id.length === 0 || id.length > 64) throw bad('id'); return { id, @@ -47,11 +53,25 @@ function readForwarded(value: unknown): Forwarded { push: { title: string('title', 300), body: string('body', 2000), threadId, tag: string('tag', 200), ...(label === undefined ? {} : { label: label as NotificationLabel }), + ...(kind === undefined ? {} : { kind: kind as NotificationKind }), ...(badge === undefined ? {} : { badge: badge as number }), }, }; } +/** The kinds a device chose, each once, in the settings' order. */ +export function validateKinds(value: unknown, method: string): NotificationKind[] { + if (!Array.isArray(value) || value.some(kind => !NOTIFICATION_KINDS.includes(kind))) { + throw invalidParams(`${method} kinds must be a list of ${NOTIFICATION_KINDS.join(', ')}`, { field: 'kinds', expected: NOTIFICATION_KINDS }); + } + return NOTIFICATION_KINDS.filter(kind => value.includes(kind)); +} + +/** Whether a subscription receives a push of this kind. One without a kind, the test, goes to all. */ +function wants(subscription: Subscription, kind: NotificationKind | undefined): boolean { + return kind === undefined || subscription.kinds === undefined || subscription.kinds.includes(kind); +} + /** An authenticated phone cannot turn push delivery into a request to a local service. */ export function validateSubscription(value: Subscription): Subscription { let url: URL; @@ -70,7 +90,8 @@ export function validateSubscription(value: Subscription): Subscription { } try { ECDH.convertKey(Buffer.from(value.keys.p256dh, 'base64url'), 'prime256v1'); } catch { throw invalidParams('push.subscribe keys.p256dh must be a valid P-256 public key'); } - return { endpoint: url.href, keys: { ...value.keys } }; + const kinds = value.kinds === undefined ? undefined : validateKinds(value.kinds, 'push.subscribe'); + return { endpoint: url.href, keys: { ...value.keys }, ...(kinds === undefined ? {} : { kinds }) }; } /** Loaded by the core without loading the encryption library or making a network request. */ @@ -80,7 +101,7 @@ export class PushStore { private closed = false; private keysPromise: Promise | null = null; /** By tag, the pushes held back while their thread is watched (`notify`). */ - private readonly held = new Map; at: number }>(); + private readonly held = new Map(); private heldTimer: ReturnType | undefined; /** When each forwarded push arrived, by sender and id (`relayed`). */ private readonly forwards = new Map(); @@ -94,7 +115,8 @@ export class PushStore { } else if (name === 'permission.requested' || name === 'question.asked') { const request = payload as RpcEvents['permission.requested'] | RpcEvents['question.asked']; const text = requestExcerpt(request); - this.notify(request.threadId, text ? { body: text } : { body: 'Needs your answer', label: 'needsYou' }, `request-${request.id}`, true); + const kind = requestKind(request); + this.notify(request.threadId, text ? { body: text, kind } : { body: 'Needs your answer', label: 'needsYou', kind }, `request-${request.id}`, true); } else if (name === 'turn.finished') { const turn = payload as RpcEvents['turn.finished']; if (this.closed || this.core.journal.isClosed() || (turn.status !== 'done' && turn.status !== 'error') || !this.reachable()) return; @@ -103,8 +125,9 @@ export class PushStore { // The reply is the news; an error's own message is often a stack or a // provider's JSON, so the phone says only that the turn failed. const reply = turn.status === 'done' ? this.lastReply(turn.threadId, turn.id) : null; - this.notify(turn.threadId, reply ? { body: reply } - : turn.status === 'done' ? { body: 'Done', label: 'done' } : { body: 'The agent encountered an error', label: 'failed' }, `turn-${turn.id}`); + const kind = turn.status === 'done' ? 'finished' : 'errors'; + this.notify(turn.threadId, reply ? { body: reply, kind } + : turn.status === 'done' ? { body: 'Done', label: 'done', kind } : { body: 'The agent encountered an error', label: 'failed', kind }, `turn-${turn.id}`); } }); } @@ -165,7 +188,8 @@ export class PushStore { async status(sessionId: string | null) { const id = this.requireSession(sessionId); - return { publicKey: (await this.keys()).publicKey, subscribed: Boolean(this.subscriptions()[id]) }; + const subscription = this.subscriptions()[id]; + return { publicKey: (await this.keys()).publicKey, subscribed: Boolean(subscription), ...(subscription ? { kinds: subscription.kinds ?? [...NOTIFICATION_KINDS] } : {}) }; } subscribe(sessionId: string | null, input: Subscription) { @@ -188,6 +212,20 @@ export class PushStore { return { ok: true } as const; } + /** The kinds this device's subscription receives from now on: the subscription stays, only what reaches it changes. */ + setKinds(sessionId: string | null, params: RpcParams<'push.kinds'>) { + const id = this.requireSession(sessionId); + const kinds = validateKinds(params?.kinds, 'push.kinds'); + const all = this.subscriptions(); + const subscription = all[id]; + if (!subscription) throw refused('Enable notifications on this device first'); + all[id] = { ...subscription, kinds }; + this.core.journal.append({ type: 'push.kinds', threadId: null, version: 1, payload: { sessionId: id, kinds } }, () => { + this.core.journal.setSetting(SUBSCRIPTIONS, all); + }); + return { ok: true } as const; + } + remove(sessionId: string) { const all = this.subscriptions(); if (all[sessionId]) { @@ -227,7 +265,7 @@ export class PushStore { * quiet or a lease nobody renews sends it. Requests for input go immediately: * an attended thread does not prove the user noticed its question or permission. */ - private notify(threadId: string, text: Pick, tag: string, needsInput = false) { + private notify(threadId: string, text: News, tag: string, needsInput = false) { if (this.closed || !this.reachable()) return; if (!needsInput && this.core.threads.focus.attended(threadId)) { this.held.set(tag, { threadId, text, at: Date.now() }); @@ -261,11 +299,13 @@ export class PushStore { } } - private deliverAll(threadId: string, text: Pick, tag: string) { + private deliverAll(threadId: string, text: News, tag: string) { const title = this.core.journal.getThread(threadId)?.title ?? 'Boite'; const badge = this.badge(threadId); const subscriptions = this.subscriptions(); - for (const sessionId of Object.keys(subscriptions)) { + for (const [sessionId, subscription] of Object.entries(subscriptions)) { + // The device turned this kind off: it stays subscribed and hears nothing of it. + if (!wants(subscription, text.kind)) continue; // A device of the group may run a page no machine serves: its push says which machine the thread is on. const self = this.core.group.clients.deviceOf(sessionId) === undefined ? undefined : this.core.coordination.card().coreId; this.track(this.deliver(sessionId, { title, ...text, threadId, tag, badge, ...(self === undefined ? {} : { core: self }) }).catch(() => { @@ -282,8 +322,9 @@ export class PushStore { if (target === null || forwarded.has(target.device)) continue; forwarded.add(target.device); // Cut to what the home machine reads (`readForwarded`), so a long title or reply still arrives. + // The home holds the device's subscription and its kinds: it filters, from the kind sent along. const payload: Forwarded = { id: randomUUID(), device: target.device, push: { title: title.slice(0, 300), body: text.body.slice(0, 2000), - ...(text.label === undefined ? {} : { label: text.label }), threadId: threadId.slice(0, 128), tag: tag.slice(0, 200), badge } }; + ...(text.label === undefined ? {} : { label: text.label }), kind: text.kind, threadId: threadId.slice(0, 128), tag: tag.slice(0, 200), badge } }; this.track(this.forward(target.home, payload).catch(() => { this.core.log('warn', `a notification for a device of ${target.home.name} did not reach that machine; the conversation remains available in Boite`); })); @@ -310,7 +351,10 @@ export class PushStore { const key = `${from.coreId}:${id}`; if (this.forwards.has(key)) return { delivered: true }; this.forwards.set(key, now); - if (this.closed || !this.subscriptions()[sessionId]) return { delivered: false }; + const subscription = this.subscriptions()[sessionId]; + if (this.closed || !subscription) return { delivered: false }; + // Received and dropped on purpose: the device turned this kind off. + if (!wants(subscription, push.kind)) return { delivered: true }; // The icon counts the threads waiting here as well as there. const badge = (push.badge ?? 0) + this.badge(); // A tag of that machine's never replaces a notification of this one's. @@ -348,7 +392,7 @@ export class PushStore { const subscription = this.subscriptions()[sessionId]; if (!subscription) return; try { - await this.send(subscription, JSON.stringify(payload), keys, createHash('sha256').update(payload.tag).digest('base64url').slice(0, 32)); + await this.send({ endpoint: subscription.endpoint, keys: subscription.keys }, JSON.stringify(payload), keys, createHash('sha256').update(payload.tag).digest('base64url').slice(0, 32)); } catch (error) { const status = (error as { statusCode?: number }).statusCode; const gone = (status === 404 || status === 410) && this.subscriptions()[sessionId]?.endpoint === subscription.endpoint; @@ -387,4 +431,5 @@ export function registerPushMethods(core: Core) { core.router.register('push.subscribe', (params, ctx) => core.push.subscribe(ctx.connection.identity.sessionId, params)); core.router.register('push.unsubscribe', (_params, ctx) => core.push.unsubscribe(ctx.connection.identity.sessionId)); core.router.register('push.test', (_params, ctx) => core.push.test(ctx.connection.identity.sessionId)); + core.router.register('push.kinds', (params, ctx) => core.push.setKinds(ctx.connection.identity.sessionId, params)); } diff --git a/packages/core/test/group-relay.test.ts b/packages/core/test/group-relay.test.ts index fd9f4a36d..f8c85f462 100644 --- a/packages/core/test/group-relay.test.ts +++ b/packages/core/test/group-relay.test.ts @@ -326,12 +326,27 @@ test('a notification of another member reaches a phone through the machine it in await expect(b.core.coordination.request(b.core.group.peers()[0]!, 'group.push', { id: 'y', device: `${id(a)}:${phone.session!.id}`, push: { title: 't', body: 1, threadId: null, tag: 'x' } })) .rejects.toThrow('push.body'); + // The phone kept only approvals on a: b still sends every kind with its kind, and a filters at home. + await phone.call('push.kinds', { kinds: ['approvals'] }); + const forwarded: string[] = []; + const forward = b.core.push.forward; + b.core.push.forward = (peer, payload) => { forwarded.push(payload.push.kind ?? 'none'); return forward(peer, payload); }; + b.core.bus.emit('question.asked', { id: 'muted', threadId, text: 'Which branch?' } as RpcEvents['question.asked']); + b.core.bus.emit('permission.requested', { id: 'kept', threadId, turnId: 't', toolName: 'Edit', input: {}, description: null, createdAt: Date.now() }); + await waitFor(() => onA.length === 4); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(forwarded.sort()).toEqual(['approvals', 'questions']); + expect(onA).toHaveLength(4); + expect(onA[3]).toMatchObject({ tag: `${id(b).slice(0, 16)}:request-kept`, kind: 'approvals', core: id(b) }); + b.core.push.forward = forward; + await phone.call('push.kinds', { kinds: ['approvals', 'questions', 'finished', 'errors'] }); + // Revoked on a: b drops its key and forwards nothing more. a.core.sessions.revoke(phone.session!.id); await waitFor(() => b.core.sessions.list(null).length === 0); b.core.bus.emit('question.asked', { id: 'revoked', threadId, text: 'Which branch?' } as RpcEvents['question.asked']); await new Promise((resolve) => setTimeout(resolve, 200)); - expect(onA).toHaveLength(3); + expect(onA).toHaveLength(4); owner.close(); phone.close(); }); diff --git a/packages/core/test/push.test.ts b/packages/core/test/push.test.ts index 64f768e4f..4b605b938 100644 --- a/packages/core/test/push.test.ts +++ b/packages/core/test/push.test.ts @@ -81,7 +81,7 @@ test('a recreated push store retains its keys and delivers completed turns to th const deliveries: { threadId: string; body: string }[] = []; restarted.send = async (_target, payload) => { deliveries.push(JSON.parse(payload)); }; try { - expect(await restarted.status(paired.id)).toEqual({ ...before, subscribed: true }); + expect(await restarted.status(paired.id)).toEqual({ ...before, subscribed: true, kinds: ['approvals', 'questions', 'finished', 'errors'] }); const client = await harness.connect(); const { threadId } = await echoThread(harness, client); // The body is the start of the reply, as one line of plain text cut at a word. @@ -330,3 +330,63 @@ test('with no subscription and no other member, a finished turn prepares no push expect(forwarded).toEqual([]); } finally { walk.mockRestore(); badge.mockRestore(); } }); + +test('a device that kept only approvals gets the permission push and not the finished turn, and changes its kinds without subscribing again', async () => { + const paired = session(); + const phone = await connect(harness.url, paired.token); + const deliveries: { tag: string; kind?: string }[] = []; + harness.core.push.send = async (_target, payload) => { deliveries.push(JSON.parse(payload)); }; + try { + await phone.call('push.subscribe', { ...subscription(), kinds: ['approvals'] }); + expect(await phone.call('push.status', {})).toMatchObject({ subscribed: true, kinds: ['approvals'] }); + const owner = await harness.connect(); + const { threadId } = await echoThread(harness, owner); + const thread = harness.core.journal.getThread(threadId)!; + const finish = (id: string, status: 'done' | 'error') => harness.core.bus.emit('turn.finished', { id, threadId, status, queuedAt: 1, startedAt: 1, finishedAt: 2, usage: null, error: null, execution: { providerId: thread.providerId, accountId: thread.accountId, model: null, effort: null, permissionMode: thread.permissionMode, sessionId: null, sessionGeneration: 0, selectionVersion: 0 } }); + const ask = (id: string, extra: Partial = {}) => harness.core.bus.emit('question.asked', { id, threadId, turnId: 'kind_turn', text: 'Which branch?', options: [], allowText: true, multiple: false, createdAt: Date.now(), ...extra }); + finish('kind_done', 'done'); + finish('kind_failed', 'error'); + ask('kind_question'); + ask('kind_setting', { async: true, setting: { kind: 'change', key: 'shortAnswers', value: false, previous: true, page: 'general', section: 'conversations', sensitive: false } }); + harness.core.bus.emit('permission.requested', { id: 'kind_permission', threadId, turnId: 'kind_turn', toolName: 'ExitPlanMode', input: { plan: 'Ship it' }, description: null, createdAt: Date.now() }); + await waitFor(() => deliveries.length === 2); + await Bun.sleep(30); + expect(deliveries.map(row => [row.tag, row.kind]).sort()).toEqual([['request-kind_permission', 'approvals'], ['request-kind_setting', 'approvals']]); + + // The same subscription, other kinds: what reaches it changes, nothing else. + await phone.call('push.kinds', { kinds: ['errors', 'questions'] }); + expect(await phone.call('push.status', {})).toMatchObject({ subscribed: true, kinds: ['questions', 'errors'] }); + finish('kind_done_2', 'done'); + finish('kind_failed_2', 'error'); + ask('kind_question_2'); + await waitFor(() => deliveries.length === 4); + await Bun.sleep(30); + expect(deliveries.slice(2).map(row => [row.tag, row.kind]).sort()).toEqual([['request-kind_question_2', 'questions'], ['turn-kind_failed_2', 'errors']]); + // Nothing chosen is nothing received; the test notification still goes. + await phone.call('push.kinds', { kinds: [] }); + ask('kind_question_3'); + await Bun.sleep(50); + expect(deliveries).toHaveLength(4); + await phone.call('push.test', {}); + expect(deliveries).toHaveLength(5); + await expect(phone.call('push.kinds', { kinds: ['everything'] as never })).rejects.toThrow('kinds must be a list'); + await phone.call('push.unsubscribe', {}); + await expect(phone.call('push.kinds', { kinds: ['approvals'] })).rejects.toThrow('Enable notifications'); + owner.close(); + } finally { phone.close(); } +}); + +test('a subscription stored before kinds existed receives every kind', async () => { + const paired = session(); + harness.core.push.subscribe(paired.id, subscription()); + expect((await harness.core.push.status(paired.id)).kinds).toEqual(['approvals', 'questions', 'finished', 'errors']); + const deliveries: { kind?: string }[] = []; + harness.core.push.send = async (_target, payload) => { deliveries.push(JSON.parse(payload)); }; + const owner = await harness.connect(); + const { threadId } = await echoThread(harness, owner); + harness.core.bus.emit('question.asked', { id: 'old_question', threadId, turnId: 't', text: 'Which branch?', options: [], allowText: true, multiple: false, createdAt: Date.now() }); + harness.core.bus.emit('permission.requested', { id: 'old_permission', threadId, turnId: 't', toolName: 'Edit', input: {}, description: null, createdAt: Date.now() }); + await waitFor(() => deliveries.length === 2); + expect(deliveries.map(row => row.kind).sort()).toEqual(['approvals', 'questions']); + owner.close(); +}); diff --git a/packages/ui/public/sw.js b/packages/ui/public/sw.js index 67a69723a..074b314a6 100644 --- a/packages/ui/public/sw.js +++ b/packages/ui/public/sw.js @@ -204,14 +204,18 @@ self.addEventListener('notificationclick', (event) => { const threadId = event.notification.data?.threadId; // Read as the push handler wrote it: a machine id, or nothing. const core = /^[0-9a-f]{64}$/.test(String(event.notification.data?.core ?? '')) ? event.notification.data.core : null; + // A page's notice about a machine with no group id: the open window finds the thread by its own key for it. + const key = typeof event.notification.data?.key === 'string' && event.notification.data.key.length <= 600 ? event.notification.data.key : null; const url = new URL('/', self.location.origin); if (typeof threadId === 'string') url.searchParams.set('thread', threadId); if (typeof threadId === 'string' && typeof core === 'string') url.searchParams.set('member', core); + // Opened with no window, or one refusing focus: the boot reads the machine from the key (`takeNotificationTarget`). + else if (typeof threadId === 'string' && key !== null) url.searchParams.set('key', key); event.waitUntil((async () => { const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const existing = windows.find(client => new URL(client.url).origin === url.origin); if (existing) { - existing.postMessage({ type: 'boite.open-thread', threadId: typeof threadId === 'string' ? threadId : null, ...(typeof core === 'string' ? { core } : {}) }); + existing.postMessage({ type: 'boite.open-thread', threadId: typeof threadId === 'string' ? threadId : null, ...(typeof core === 'string' ? { core } : key !== null ? { key } : {}) }); // iOS can refuse to focus a suspended home-screen app; opening the URL then reaches the thread. try { await existing.focus(); } catch { await self.clients.openWindow(url.href); } } else await self.clients.openWindow(url.href); diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte index 20b65c360..7810ba0cf 100644 --- a/packages/ui/src/App.svelte +++ b/packages/ui/src/App.svelte @@ -253,7 +253,8 @@ window.addEventListener('pageshow', pageshow); const notification = (event: MessageEvent) => { if (event.data?.type !== 'boite.open-thread' || typeof event.data.threadId !== 'string') return; - void workspace.openFromWorker(event.data.threadId, event.data.core); + if (typeof event.data.key === 'string' && event.data.core === undefined) void workspace.openNotification(event.data.key); + else void workspace.openFromWorker(event.data.threadId, event.data.core); mobileScreen = 'chat'; }; navigator.serviceWorker?.addEventListener('message', notification); diff --git a/packages/ui/src/components/GeneralSettings.svelte b/packages/ui/src/components/GeneralSettings.svelte index 286e72b9d..03cc042a5 100644 --- a/packages/ui/src/components/GeneralSettings.svelte +++ b/packages/ui/src/components/GeneralSettings.svelte @@ -11,6 +11,7 @@ import { openTour } from '../lib/onboarding.svelte'; import type { Store } from '../lib/store.svelte'; import ConversationSwitches from './ConversationSwitches.svelte'; + import NotificationKinds from './NotificationKinds.svelte'; /** * What the app does for the person in front of it: how a thread @@ -43,6 +44,7 @@ void store.setNotifications(event.currentTarget.checked)} /> + {#if store.owner}