char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somnotify a finished turn even while its conversation is open, if the window lost focusc823fd4

main
2.8 KiB73 linesraw
1/** Announcing a finished turn, so the reader hears about it from the next tab over. */
2
3const KEY = "berry.notifications";
4
5/** Whether this browser announces finished turns; permission belongs to the browser, so the choice does too. */
6export function notificationsOn(): boolean {
7  return localStorage.getItem(KEY) === "on";
8}
9
10/** Whether this page can announce at all: browsers keep notifications to secure contexts. */
11export const notificationsAvailable = () => typeof Notification !== "undefined";
12
13/** Switch announcements on or off, asking for permission first; resolves whether they are on. */
14export async function setNotifications(on: boolean): Promise<boolean> {
15  if (!on) {
16    localStorage.removeItem(KEY);
17    return false;
18  }
19  if (!notificationsAvailable()) return false;
20  // browsers only grant permission from a gesture, so the settings switch has to be the ask
21  const permission = Notification.permission === "default"
22    ? await Notification.requestPermission()
23    : Notification.permission;
24  if (permission !== "granted") return false;
25  localStorage.setItem(KEY, "on");
26  return true;
27}
28
29export type Notice = { tid: string; title: string; body: string };
30
31/** What announcing needs of a conversation's summary. */
32type Settled = { busy: boolean; unread: boolean; reply?: string };
33
34/**
35 * Follow a summaries feed and announce the conversations that stopped answering since the last one, except whichever
36 * one is on screen in a window someone is looking at.
37 */
38export function announcements(options: {
39  /** The conversation in view. */
40  active: () => string;
41  /** What to call a conversation. */
42  name: (tid: string) => string;
43  /** Where announcements go: the browser, unless a test says otherwise. */
44  speak?: (notice: Notice) => void;
45}): (summaries: Record<string, Settled>) => void {
46  const speak = options.speak ?? speakThroughBrowser;
47  let previous: Record<string, Settled> = {};
48  return (next) => {
49    const before = previous;
50    previous = next;
51    if (!notificationsOn()) return;
52    for (const [tid, summary] of Object.entries(next)) {
53      if (before[tid]?.busy !== true || summary.busy || !summary.unread) continue;
54      // an open conversation still deserves a notice once the reader looks elsewhere
55      if (
56        document.visibilityState === "visible" && document.hasFocus() && options.active() === tid
57      ) {
58        continue;
59      }
60      speak({ tid, title: options.name(tid), body: summary.reply || "finished answering" });
61    }
62  };
63}
64
65function speakThroughBrowser({ tid, title, body }: Notice): void {
66  if (!notificationsAvailable() || Notification.permission !== "granted") return;
67  const notice = new Notification(title, { body, tag: tid });
68  notice.onclick = () => {
69    document.defaultView?.focus();
70    location.hash = `#/${tid}`;
71    notice.close();
72  };
73}