chat notifications reach the phone, on the same rule as the bell

the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.

deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.

two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.

`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.

dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.

the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
This commit is contained in:
2026-08-19 22:00:17 +00:00
parent 72d327e360
commit 46d621c91b
5 changed files with 109 additions and 10 deletions
+53
View File
@@ -0,0 +1,53 @@
import * as sidecar from '@@/sidecar-registry';
import { getNotifyServerUrl } from '../notify/router';
import { shouldNotify, type NotifyKind } from './notify-decision';
// The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM.
//
// ── Why this is a second subscriber and not a branch inside notify-delivery ──
//
// The two channels have nothing in common except the question "does this account want to hear about
// this". That question lives in `notify-decision.ts` and both ask it; everything else differs. The
// browser needs an open tab and sends the chat's real title over a loopback socket on the owner's own
// tailnet. The phone needs no tab at all and must send NO title, because that hop goes through Apple.
//
// Folding both into one function would mean a `if (isPush)` inside every step of it, and the first thing
// to go wrong would be a title leaking into a payload because the branch was missed in one place.
/** Which code a kind maps to. Both live in `notifications/catalogue.ts` as `officer/core:chat.*`. */
const CODE_FOR: Record<NotifyKind, string> = {
'turn-ended': 'officer/core:chat.turn-ended',
'input-needed': 'officer/core:chat.input-needed',
};
/**
* Subscribe the push channel to chat's notify signal.
*
* Called explicitly from `server.tsx` rather than run on import: a module whose only purpose is a side
* effect is one an unused-import sweep deletes, and the failure would be silent.
*/
export function registerChatPushNotifications(): void {
sidecar.onClaudeNotify(async (sessionKey, userId, kind) => {
try {
if (!(await shouldNotify(userId, sessionKey, kind))) return;
const base = getNotifyServerUrl();
// The sidecar has not reported in — either it is starting, or push is not running on this install.
// Not an error: the browser bell still works, and this is the channel that is allowed to be absent.
if (!base) return;
// `id` is the session key so the device can deep-link straight to the conversation. It is an
// opaque uuid, which is the only reason it may travel: NO title, and no message content, because
// this payload is read by Apple and Google. See docs/push-notifications.md.
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
// A notification that fails must never disturb the turn that produced it.
console.error('[chat:push] failed to dispatch:', err instanceof Error ? err.message : err);
}
});
}