Skip to content

Plannotator Inbox: browser notifications from the open page (step 4) - #1756

Merged
backnotprop merged 2 commits into
mainfrom
feat/inbox-notifications
Oct 7, 2026
Merged

backnotprop merged 2 commits into
mainfrom
feat/inbox-notifications

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

The story

The open Inbox page now raises a desktop notification when an agent stops on the person or asks a question, through the browser's own Notification API (owner Q8: the page first on every platform, the Mac helper later). The page asks once, in its own words, only after the first thread arrives; "Not now" is kept by the Inbox (config.json), so it survives the Inbox moving to another port. Every Inbox notification shares one tag. If one thread raised since the tab was last in front, the notice names that thread and a click opens it. If more did, the burst becomes one notice, "3 waiting in 2 projects", and a click opens the list. Nothing is raised while the Inbox tab is in front.

What changed

  • packages/inbox/notify.ts (new): useInboxNotifications. It reads the event stream's lines. An item is a new question in an agent's message: a question line at revision 0 and state open, counted once per page. A Stopped: line, a Holds up: line or any other question counts; news never does. After the list read those lines trigger, it raises one Notification, always on the tag plannotator-inbox, so a new one replaces the last. While the tab is away, the page keeps the threads it notified about:
    • With one thread, the title is ": stopped on you" when the thread's list section is Stopped on you, else ": is waiting on you". The body is the thread's subject. A click calls window.focus() and opens #thread=.
    • With more than one, the title is "3 waiting in 2 projects" (or "2 waiting in billing-svc") and the body names the projects. A click opens the list at rest.
    • The set clears when the tab comes to the front. Nothing is raised while visibilityState === "visible" and document.hasFocus().
    • It also holds the ask state, Turn on (Notification.requestPermission() on the click), Not now, and the Settings switch. A browser grant or block shows without a reload (navigator.permissions change).
  • packages/inbox/components/NotificationAsk.tsx (new): the one-time line (record 6.1), with its "moved" wording.
  • packages/inbox/components/Settings.tsx: the Notifications block (record 7.2): the drawn row with its one switch.
  • packages/inbox/App.tsx, components/InboxList.tsx (an ask slot above the notice), icons.tsx (the record's bell), inbox.css (ib-askline, ib-srow .ib-d).
  • packages/inbox/api.ts: the event stream hands each parsed line to onRecord (InboxEvent). SettingsModel.notifications and saveNotifications are added.
  • packages/server/inbox.ts: the settings field and nothing else. GET /api/inbox/settings returns notifications: { enabled, dismissed, allowed_origin }. POST accepts notifications (field by field, validated) beside inbox_tool, behind the same same-origin and serverSession guards. The values are stored in config.json as inboxNotifications. packages/shared/config.ts adds only that typed key.
  • tests/e2e/notifications.spec.ts (new), tests/e2e/playwright.config.ts (runs both specs), .github/workflows/inbox-e2e.yml (adds packages/shared/config.ts to its paths, and a comment), AGENTS.md (a "Notifications" paragraph in the Inbox section).

Record sections this makes real

  • 6.1, the one-time ask with the reason, drawn in the page. The browser's prompt opens only on Turn on. The port-change line "The Inbox moved to a new address. Allow notifications again." returns once. Proof frames: 6.1-ask, 6.1-moved.
  • 6.2, the notification: the title names the project and the agent, the body is the subject, and a click opens the thread. A burst becomes one notice, "3 waiting in 2 projects", whose click opens the list. Proof: 6.2-notification.json, 6.2-burst.json, 6.2-click-opened-thread and 6.2-burst-opened-list. The macOS banner itself is the browser's and cannot be captured headless.
  • 6.3, silent while the Inbox is focused: the "1 new" notice is enough. Proof frame: 6.3-focused-silent.
  • 7.2, Settings: "Allowed in this browser · for questions and stops" with the switch. Proof frame: 7.2-settings-notifications.

Proofs (all run against the compiled binary, under a temp PLANNOTATOR_DATA_DIR and HOME)

Agents write through plannotator inbox mcp with the MCP SDK's stdio client (scripts/inbox-sim.ts). The permission is Chromium's own, granted per origin through context.grantPermissions(['notifications'], { origin }). new Notification is the real constructor; an init script subclasses it only to record the calls.

bun run test:e2e:inbox. Build first: bun run --cwd apps/review build && bun run build:hook && bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --define '__CLI_VERSION__="0.0.0-dev"' --outfile .local/plannotator. All 20 pass (the window's 10, this PR's 10):

  1. The ask is absent on the empty first paint. It appears when the first thread arrives, and nothing has been raised yet. After a reload, the list paints without it.
  2. "Not now" holds across a reload with a new item arriving. It also holds after a port change: the Inbox is stopped, its port is held by another listener, and it restarts on a new origin. config.json has dismissed: true.
  3. Settings shows "Allowed in this browser" with one switch. Off and on save enabled and allowedOrigin. After a second port change, the new origin's permission reads default and the "moved" line appears. Turn on calls the browser's requestPermission once for that origin, and allowedOrigin follows.
  4. With permission and the tab in the background, the simulation's Stopped thread (two items: a Stopped: line and a Holds up: line) gives exactly one call: { title: "billing-svc: Pi stopped on you", body: "Which way should the worker go on a Stripe 409?", tag: "plannotator-inbox" }.
  5. A plain question added to the same thread replaces that notice. Because the title follows the thread's section, it still reads "billing-svc: Pi stopped on you".
  6. A focused, visible page raises nothing; the "1 new" notice shows instead.
  7. A single thread's click opens #thread=<id> with the new question in the pane.
  8. A burst while the tab is away: three threads arrive in two projects.
    • The first gets its own banner. It is a Holds up thread, so it reads "docs-site: Claude Code is waiting on you".
    • The next two replace it on the same tag, as "2 waiting in 2 projects" and then "3 waiting in 2 projects".
    • The click lands on the list at rest: no thread open, no "N new", and all three rows on screen.
  9. With the Settings switch off, a Stopped item raises nothing. With it back on, the next item in that thread notifies.
  10. No page errors.

Also packages/server/inbox.test.ts: "notifications save into config.json …" (real fetch). It checks the defaults, refuses bad fields (422, nothing written; sections is no longer a setting), and saves field by field.

PNGs, light and dark at 1440 by 900, are in .local/proof/notifications/.

Gates run

  • bun run typecheck: green.
  • bun run check:release-version: green.
  • After the review fixes: typecheck, packages/server/inbox.test.ts, packages/inbox, bun test tests and the full e2e (20 of 20) re-run green.
  • Before them, bun test packages apps/hook tests scripts under a temp data dir: 5781 pass, 1 fail. The failure is apps/hook/server/annotate-cli.test.ts, "exits 1 with one line naming the port…". It fails the same way on a clean origin/main checkout on this machine (checked), so it is unrelated to this PR. tests/entry-assets.test.ts and tests/favicon-surfaces.test.ts are in that run and pass.
  • No DOM-gated test files were added. packages/ui and packages/core are untouched, so no ui smoke was needed.

Leaves out

  • The Mac helper and any closed-page notification (step 8).
  • Web Push and service workers.
  • A notification for news, Sent or Quiet rows: the record says "never for news".

Deviations from the record, with the reason

The independent review (research/reviews/REVIEW-PLANNOTATOR-INBOX-notifications-2026-10-07.md) had three blockers, all fixed in 575205a61:

  • The per-section switches are removed, so Settings is the drawn single switch.
  • A burst becomes one notice, as 6.2 draws it.
  • The undrawn Settings states are justified below.

The title now follows the thread's section, and "is holding up work on you" is gone.

  1. The Settings states the record does not draw. 7.2 draws only "Allowed in this browser · for questions and stops" with the switch on. The row's label reports the browser's own answer for this origin, which the person cannot see elsewhere and which decides whether anything can show:

    • "Not allowed in this browser yet": the switch reads off. Turning it on raises the browser's prompt.
    • "Blocked in this browser": the switch is disabled, with one note, "This browser blocks notifications for this page: allow them in its site settings." The page cannot ask again once the browser has blocked it, so the note names the only way out.
    • "This browser cannot show notifications": no Notification API; the switch is disabled.

    Each label keeps the drawn row's shape and "for questions and stops".

  2. When the ask shows. Record 6.1 says "the first time something waits on him". The brief says "after the first thread arrives, never on first paint". The ask is raised when a new waiting item arrives through the event stream and the browser has not decided. A reload with waiting rows already present does not show it until the next item arrives.

  3. Title wording. Record 6.2 draws only the stopped title ("billing-svc: Claude Code stopped on you"). A thread in any other waiting section, Holding up work included, reads ": is waiting on you". The burst title is the record's "3 waiting in 2 projects". With one project it reads "2 waiting in billing-svc", as the list's own "1 new in docs-site" does. The burst's body names the projects (not drawn).

  4. The server's notice string ("The Inbox moved to a new port…", from the window PR) is still not drawn. The page draws the record's own "moved" line, keyed on the origin where the person turned notifications on.

  5. Test seams, named. The spec runs Chromium's new headless mode (channel: 'chromium'), because the headless shell answers every notification permission with "denied". An init script handles three things a test cannot do with the real browser:

    • It sets whether the tab is in front (visibilityState, hasFocus, and the focus and visibilitychange events a browser fires), because Playwright reports every page as focused.
    • It lets the context's grant answer the browser's prompt, because the prompt is browser chrome a test cannot click. The real requestPermission still runs and is recorded.
    • It subclasses the real Notification to record its calls.

Stacking

This was built on feat/inbox-window and rebased onto main after PR 1755 merged (4ef1659f0). It has no other lane dependency. It touches packages/server/inbox.ts only in the settings route and two helpers next to saveInboxTool.

The ask drawn in the page once an item arrives (Not now kept in config.json,
so it survives a port change; a moved line after a port change), one
Notification per thread tagged by the thread id, silent while the tab is in
front, a click opening the thread, and the Settings block (on or off, per
section). Proved in Chromium against the compiled binary.
…es one notice, title from the thread's section)

Per-section switches removed from the page, the server, config.ts and the
spec. Every notification shares one tag; with one thread notified since the
tab was in front the banner names it and a click opens it, with more it reads
"3 waiting in 2 projects" and a click opens the list. The title follows the
thread's list section: stopped on you, else waiting on you. The e2e workflow
also runs on packages/shared/config.ts.
@backnotprop
backnotprop merged commit d5e0062 into main Oct 7, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant