eleven / messenger Public

Mini-apps platform: encrypted app framework, scheduling/snapshots/notifications — and chat itself as the default app (#93)

* web: mini-app framework + /todo shared list

Mini-apps are small client-side programs that trade typed events over the
same end-to-end-encrypted thread stream chat uses. Because the server only
ever sees ciphertext, an app lives entirely in the browser and derives its
state by folding the thread's event stream — the encrypted thread log IS
the app's database. An app = a command that emits a typed event, a reducer
that folds events into state, and a surface that renders it; apps listen
for the event types they own and ignore the rest (the same model as chat,
and unified with bots, which already emit typed payloads).

- apps.js: defineApp, the event bus (isAppType/foldEvent), command parsing
  (parseCommand/runCommand), the emit transport, panel/stream surfaces.
- apps/todo.js: /todo -> todo.add; reducer over add/done/reopen/remove;
  a live panel above the feed + a subtle line in the message stream.
- chat.js: /command intercept in the composer, app-event fold in
  appendMessage + ws.onmessage (folded, never a chat bubble), surface
  refresh, plus the transport + renderActiveSurfaces definitions.
- index.html: #app-surface mount point; style.css: panel + stream styles.
- test/apps.test.mjs: 14 tests (parse, run->emit, fold, reduce, isolation).

State folds the loaded message window; the reducer is snapshot-ready for a
later per-(thread,app) encrypted snapshot blob so state can survive reload
and late-join. First-party only for now — a mini-app runs in the origin
that holds the private key, so third-party apps would need a sandbox.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: mini-apps — self-contained app CSS + idempotent event fold

Two refinements to the mini-app framework:

1. Apps ship their own CSS. defineApp injects an app's `styles` string once
   (keyed, browser-only), and the framework injects its own panel/line chrome
   — so nothing mini-app-related lives in the shared style.css. A mini-app is
   now a single self-contained file (JS + CSS). Styles still read the theme's
   CSS variables, so they follow the chosen theme.

2. Fix duplicate items on thread re-open. catchUpActiveMessages re-reads the
   message window on every reopen and skips already-rendered *chat* messages via
   msgEls — but app events never register there, so each todo event was folded
   again, adding a duplicate item each time. foldEvent now dedupes on the unique
   server message id (meta.id): re-folding the same message is a no-op and
   returns null (so no duplicate stream line either). resetThread clears the
   folded-id set so a fresh window rebuild still folds cleanly.

Adds a regression test for the idempotent fold.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: mini-apps as sidebar app-threads + poll/countdown/bookmarks

Reframes mini-apps around the user's model: an app is its own thing in the
sidebar, and a personal app's command files into YOUR instance of it wherever
you type it.

- Personal apps (todo, countdown, bookmarks) each get their own single-member
  thread, shown in the sidebar with an "app" badge and rendered full-pane. The
  thread is a plain `kind:"self"` thread whose app id rides ENCRYPTED in
  name_blob — the server can't tell a todo from a poll from "Notes" (zero new
  schema, zero server change, zero metadata leak). Dedupe + rendering are
  client-side.
- `/todo` typed in any chat routes to your todo app-thread (created on first
  use), not the chat, with a toast confirming where it went. Shared apps (poll)
  still run in the current chat as a panel.
- ctx gains getState() (a derived read over the folded log — one source of
  truth, no second store), me (current user id, for poll's "your vote"), and
  the framework injects app CSS so each app is a single self-contained file.
- New apps: poll (shared, live vote tally), countdown (personal, live timers),
  bookmarks (personal, /save links). Each is one self-contained file.
- The New-chat dialog gains an "Apps" section to start any app directly.

Tests cover the async command routing and every app's reducer.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: rework mini-apps — drop countdown, bookmark chat links, add /remind

Address feedback on the mini-apps lineup:

- Drop the Countdown app (passive timers weren't compelling).
- Bookmarks now saves links posted in chat, not just hand-typed URLs.
  New framework extension point: an app contributes a message action via
  a `messageAction(msg)` hook. Bookmarks offers "Save link" on any message
  with a link (detected via the feed's own firstLink), wired into both the
  desktop picker and the mobile long-press sheet. Saving files it into your
  personal bookmarks with "Shared by <name>" attribution — encrypted into
  your own self-thread, so the server still can't tell a bookmark from a todo.
- Add a /remind app: `/remind Call the plumber in 2h`, `at 9:00`, or
  `| 2026-08-01`. Reminders gather in their own sidebar app, soonest-first.
  Firing is best-effort client-side (a due reminder raises a browser
  notification while the app is open) — reliable firing when closed would
  need a server-scheduled push and is left as a deliberate follow-up.

Tests: reminder time-parser + reducer, bookmarks `from` attribution, and
message-action routing into the app-thread.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: declarative mini-app surface (events map) + authoring guide

Rework the internal app-authoring API so a mini-app is one declaration with
no silent-failure footguns, and document it for humans and LLMs.

- Single source of truth for events: apps declare an `events` map whose keys
  ARE the owned types and whose values fold each event. `defineApp` derives
  `app.types` and `app.reduce` from it, so a handled type can never drift out
  of the owned-type set (the old three-way sync between the `types` array, the
  reduce switch, and emitted strings is gone), and apps no longer write a
  `default` case (the framework returns state unchanged for unowned types).
- Collection helpers `addItem`/`updateItem`/`removeItem` encode the two
  invariants reducers kept getting subtly wrong — every op returns a fresh
  array, and addItem dedupes by id so folding a re-read window never dupes.
- `ctx.interval(fn, ms)` / `ctx.onCleanup(fn)`: framework-managed render
  teardown, run before each redraw and on resetThread. Replaces remind's
  `el._remindTimer` hack; navigating away now stops the ticker too.
- Drop dead `ts: Date.now()` from emits — the transport already injects
  `name`/`ts`/`uid` on every event; document these as free fields.
- Migrate all four apps (todo, poll, remind, bookmarks) to the one pattern;
  behaviour is unchanged (poll keeps newest-first via a render reverse).
- web/static/apps/README.md: a complete authoring guide — mental model, a
  copy-pasteable reference app, full API reference, and the invisible
  invariants (idempotent fold, injected fields, textContent-not-innerHTML,
  personal-vs-shared render contract).
- Tests: cover the collection helpers and events-derived types; all green.

Co-Authored-By: Claude <noreply@anthropic.com>

* server+web: mini-app platform — scheduling, snapshots, notifications

Three capabilities for every mini-app, all E2E-preserving (the server only
ever holds ciphertext it cannot open):

- Time: ctx.schedule(event, when) — a "future message". The event is
  encrypted NOW; the server (miniapps.go: scheduled_messages + a sweep
  goroutine) holds the blob and at the due moment moves it into the thread's
  message log transactionally (crash-safe, delivered exactly once) and fans
  it out like any posted message — WS to open clients, push to closed ones,
  with NO sender exclusion (your own reminder must push you). Cancellable
  from any device via DELETE /api/schedule/{id}; the server learns only
  (thread, time).
- Memory: automatic state snapshots. Folded app state is saved as one
  encrypted blob per thread (thread_state, optimistic versioning; 409 →
  refold and retry) and re-seeded on open, folding only events newer than
  its horizon. State now outlives the ~100-message window — apps change
  nothing to opt in. Also seeds prebuilt/cached threads.
- Voice: an event may carry an encrypted notice {title, body} — live
  clients toast it, hidden pages raise a system notification, and sw.js
  decrypts the push and shows it when the app is closed. Optional
  notify(event, ctx) hook for state-dependent wording. App events with
  neither stay silent (fixes empty "New message" pushes for app events).

remind.js is the exemplar: /remind now schedules a real server-delivered
remind.fire (schedule first, then record the sid in remind.add so any
device can cancel); the client ticker is demoted to an offline fallback
for sid-less items.

Docs: web/static/apps/README.md gains Time/Memory/Voice sections, the
server-surface trust table, the bot/LLM mixing pattern, and the "chat is
almost an app" design note; CLAUDE.md architecture list + changelog updated.

Tests: 4 new Go tests (schedule auth/validation, deliver-once, cancel,
state versioning — 68 total) and 6 new node tests (snapshot seeding +
horizon skip, takeSnapshot semantics, schedule-then-record, offline
fallback, notice toasting).

Co-Authored-By: Claude <noreply@anthropic.com>

* web+server: chat as a mini-app (lab) — the existing chat, retooled

Prove "chat is just an app" on the framework, behind a per-device flag
(localStorage lchat-chat-lab=1): lab group/DM threads render a chat mini-app
full-pane instead of the classic feed. No new wire protocol — the lab
composer emits the classic untyped {text} payload, and the app folds exactly
what the classic feed consumes: stored payloads (text, attachment metadata,
replyTo) keyed by server message id, plus the server's own reaction/edit/
delete frames. Classic and lab clients read and write one protocol.

Framework primitives this needed (all documented in apps/README.md):
- ephemeral events: an `ephemeral` envelope flag on WS msg frames makes the
  server relay-and-forget (no store, no push, no id) — generalising the
  dedicated typing frame, as hub.go's comment anticipated. ctx.emitEphemeral
  + an app-level ephemeral(event, ctx) hook; ephemeral types are owned via a
  no-op fold so routing works and a stored one is harmless.
- hostEvents: fold handlers that are HOST-ONLY (never wire types), reached
  via foldHostEvent. Chat's react/edit/delete fold through these, so a
  member cannot spoof them by posting a typed message — the server-enforced
  authorship/dedupe mechanisms remain the only path in.
- snapshot: false — chat's state IS the log; also fixed the snapshot-horizon
  skip so another app's snapshot never blocks a snapshot-less app's folds.
- incremental rendering + keyedList: reconcile a list surface by key/rev so
  appends reuse every existing DOM node (scroll, focus, images survive) and
  a rev bump re-renders exactly the mutated bubble.
- scope "system": mounted explicitly by the host, never in the Apps menu.

In the lab today: bubbles (mine/theirs, names), attachments as metadata
chips, reply snippets, live reactions (tappable pills), edits (with marker),
unsend on your own messages (same confirm + server hard-delete), typing
indicators. Actions ride the classic WS frames via a narrow host bridge.

Kept server-enforced by design: unsend/edit (real ciphertext deletion — an
append-only fold could only tombstone), invites/roster. Parity roadmap:
sending attachments, reply/edit authoring, rich text, previews, pagination.

Tests: ephemeral relay (WS round-trip, nothing stored) on the server; chat
reducer (classic folds, react/edit/delete translations), wire-spoof
rejection, snapshot opt-out + horizon fix, keyedList reconciliation,
ephemeral dispatch in Node. 69 Go + 143 JS green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab parity — rich text, attachments, replies, edits, previews, paging

Bring the chat mini-app to feature parity with the classic feed, still with
zero new wire protocol — every capability reuses the classic machinery via
the host bridge, so the app never touches crypto or tokens:

- Rich text from the same pure tokenizers (blockTokens/richTokens): links,
  bold/italic/strike/code, ``` fences, > quotes — plus jumbo emoji-only
  bubbles, all textContent (never innerHTML).
- Day separators + sender-run grouping via a derived viewItems() list; the
  composite rev means a neighbour change re-renders exactly the right bubble.
- Attachments both ways: images decrypt to inline thumbnails with a
  lightbox, other files to lazy download chips; the composer stages files
  through the same downscale/allow-list/encrypt/upload path (send disabled
  while an upload is in flight, mirroring the classic rule).
- Replies: quote line on bubbles, composer banner, classic replyTo payload.
- Edits: 15-min window, full { ...orig, text } payload rebuild — the reducer
  retains each message's raw payload precisely so edits don't drop fields
  (attachment w/h, replyTo).
- Reactions: six-emoji picker (the server's allowed set) + tappable pills.
- Link extras: click-to-play YouTube cards (nocookie embed, URL never sent
  to our server) and OpenGraph unfurl cards, cached per URL.
- Scroll-back pagination: nearing the top folds the previous history page
  and anchors the scroll offset across the prepend; messages sort by server
  id so pages folded late land in order.
- Copy, unsend (same confirm + server-verified hard delete), Esc to cancel
  reply/edit, tap-to-toggle action strip on touch.

Host bridge grows edit/loadBlob/upload/unfurl/loadOlder; loadOlder also
folds any mini-app events found in older pages to their owning apps.

Remaining polish vs classic (documented): @mention highlighting, swipe
quick-reply + long-press sheet on touch, reply tallies, ↑-to-edit.

Tests: reducer raw-retention + scroll-back ordering, viewItems grouping/
separators; 69 Go + 145 JS green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab — mentions, reply tallies, swipe-reply, long-press, ↑-to-edit

Close the last parity gaps between the chat mini-app and the classic feed:

- @mentions: the same roster-label pattern as the classic renderer (longest
  label first, mid-word/email guarded), highlighted inside plain runs after
  block + inline tokenizing; tapping one opens the classic member menu via
  the host bridge (mentionNames / mention).
- Reply tallies: viewItems counts replies per parent (the reply link now
  keeps its parent id) and the tally rides the composite rev, so a new reply
  re-renders exactly its parent's bubble.
- Touch gestures: swipe-right on a message starts a quick reply (with the
  translate-while-dragging affordance), long-press opens the action strip,
  tap still toggles it.
- ↑ on an empty composer edits your latest still-editable message.

Tests cover the reply-id retention + tally-in-rev; all suites green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab composer parity — slash commands, multiline, paste/drop

Auditing what's left of the classic feed surfaced three composer gaps in the
lab, now closed:

- Slash commands: "/todo buy milk" typed in a lab thread routes through
  runCommand exactly like the classic composer (and is skipped while
  editing, so a message starting with "/" stays editable) instead of
  posting as text.
- Multiline: the composer is a textarea with the classic platform rule
  (shouldSend — desktop Enter sends / Shift+Enter newlines; touch Enter
  newlines, the send button sends), auto-growing to its content.
- Paste an image into the composer or drop files onto the feed to stage
  them — both funnel into the one staging path (host upload pipeline).

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab — app message actions (Save link) in the action strip

Other apps' messageAction contributions now appear in the lab's strip just
as they do in the classic menus — chat consumes the framework's own hook.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab renders the classic markup — one look, zero duplicated CSS

Fix the lab's visual regressions the right way: instead of a parallel
chatapp-* stylesheet, the app now emits the classic feed's exact DOM — the
same class names and structure appendMessage builds — so style.css styles
both feeds identically and they cannot drift:

- .msg/.me/.them/.cont + .content + .bubble(.jumbo-emoji), data-id/t/sender
  attributes, .name with the BOT badge (host bridge: isGroup/isAgent)
- .msg-actions hover strip (react/reply/edit icons + the exact .msg-time),
  revealed on hover exactly like classic — the icons are the classic
  in-code SVGs, passed through the bridge
- .time-sep separators with the classic rules (dated for a new day or the
  oldest message; time-only for a >15-min gap) and .cont sender runs —
  viewItems now implements the classic semantics precisely
- .reply-quote (tappable, scrolls + .flash), .reactions/.reaction-chip with
  the classic mine-set behaviour, .reply-count, .edited-tag
- .emoji-picker (six .emoji-opt + .picker-action reply/edit/delete + app
  actions) on hover-icon click, desktop press-and-hold, touch long-press /
  double-tap; classic swipe-right-to-reply with the fading .swipe-reply-icon
- .msg-image with the pre-decrypt box reservation, .file-chip, .yt-card
  (incl. the dead-video .yt-bare fallback), .link-preview/.lp-*
- the classic composer markup: .composer/.attach-btn/.composer-field/
  .send-btn (shown with content, disabled while uploading), .composer-reply
  reply-bar, .composer-edit banner, .composer-float preview chips with
  spinner → thumbnail tiles, in-feed .typing row via typingSummary

Framework: the app-mode hide rules now use child combinators, so they hide
only the classic elements and an app may reuse the classic class names
inside its pane. The app keeps ~10 lines of CSS: a display:contents item
wrapper (so .time-sep/.msg join the feed's flex flow as siblings, matching
class-scoped selectors), and a mirror of the one id-based #feed rule.

Tests updated to the classic viewItems semantics (timeSep withDay/time-only,
cont runs); 69 Go + 145 JS green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: clear the road to deleting classic chat — typeahead, hooks, embed, toggle

Address the four blockers on retiring the vestigial classic feed:

1. Opt-in toggle (toward the default flip): the ⋮ menu gains "Try the new
   chat (beta)" / "Back to classic chat" — flips the per-device flag and
   reloads, so testers don't need DevTools. The default flip itself stays
   gated on live validation.
2. Embedded (native shell) mode: the chat app now speaks the full nativePost
   contract. When the shell opts in (chatLab: true on __lchatEmbed), the
   host routes window.lchatNative — sendText / typing / stageAttachment /
   cancelReply / didAttach — to the app's composer handlers (the same code
   paths as the hidden web composer, so behaviour can't fork); the app hides
   its composer chrome and posts {replying}/{staged}/{error} back to the
   shell. Typeahead was already shared. Enabling it natively is a one-line
   Swift config change.
3. Sidebar presence hooks: apps decide their own preview/unread instead of
   the host hardcoding "app events are silent". New framework hooks
   preview(event) → line and unread(event) → bool (defaults keep the old
   silence); poll opts in — a new poll previews "📊 <question>" and lights
   the unread dot; votes stay quiet. Push wording was already covered by
   the encrypted notice field.
4. Composer typeahead: the :emoji/@mention menu, ported to the lab composer
   with the classic markup (.typeahead/.ta-item) and the classic key
   contract (arrows/Enter/Tab/Escape first, caret-move re-checks, blur
   closes); candidates come from the SAME host function that ranks the
   classic menu and the native strip. Also adds ↓-to-cancel-edit and the
   classic "message arrives → sender's typing row clears" rule.

Tests for the presence hooks; 69 Go + 146 JS green.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: developer architecture page for mini-apps

An HTML explainer (docs/mini-apps-architecture.html) with diagrams: the fold
pipeline, the four-layer system map with the E2E boundary, /remind and chat as
worked examples (primitives vs flagship; the two-doorway hostEvents/bridge
flow), an annotated UI mock pinning every shell integration point (sidebar
entries, previews/unread, composer+typeahead, message menus, notifications,
native shell), the trust ledger, and the invariants.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat lab hosts shared-app surfaces — /poll shows in lab threads

In lab mode the app owns the full pane, and the classic #app-surface (where
shared apps like poll render their panel) is hidden with the rest of the
classic chrome — so a poll folded fine (sidebar preview worked) but had
nowhere to draw. The chat app now mounts its own .app-surface pinned above
the feed, in the classic position, and delegates to the framework's
renderSurfaces on every render — same card, same place, live tally updates.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: poll — creator can close, posting the results back into the chat

A small "Close poll" link sits bottom-right on the votes line (creator only).
Closing does two things: emits poll.close, which removes the poll from
everyone's panel — creator-only enforced IN THE FOLD, so a stray close from
another member is a no-op on every honest client (legacy polls without a
recorded creator accept anyone) — and posts the results back into the chat
as a plain untyped message ("📊 Poll closed: … • counts … winner/tie/no
votes"), so classic and lab feeds alike keep a durable record, with the
normal preview/unread behaviour of any chat message.

poll.create now records the creator's uid (`by`); pollSummary is pure and
exported. Tests: creator-only close + legacy behaviour, summary verdicts
(win / tie / no votes). 148 JS + 69 Go green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: final parity sweep — catch-up, pending DMs, action chips, polish

A thorough audit of the classic feed against the chat app surfaced one
correctness gap, one gating gap, one missing feature, and six polish deltas;
all fixed:

- Reconnect/refocus catch-up (correctness): catchUpActiveMessages appended
  via the classic pipeline, which bails without a feedCache entry — lab
  threads have none, so messages missed while the socket was down never
  appeared until leaving and re-entering. Lab threads now catch up through
  the fold (labFoldMessage; per-message-id dedupe makes repeats no-ops),
  shared with history open and scroll-back paging.
- Pending DMs stay classic: the waiting-card UX and its can't-post-yet
  gating live in the classic path, so labChatApp no longer claims a DM
  whose invitee hasn't joined.
- Helper action chips: agent-proposed one-tap suggestions ("Switch to
  dark") now render in the lab — the host whitelists, labels, and runs
  them (they're host machinery); the app mounts the returned .chip-row in
  the classic position. The reducer retains e.actions.
- Copy joins the lab picker (native selection is suppressed on bubbles, so
  the menu is Copy's only home on touch — the classic sheet had it).
- Live arrivals ease in (.appear) and glide the feed (smooth scroll,
  reduced-motion aware); history builds stay instant.
- .composer.editing toggles on focus (the classic iOS keyboard-inset rule),
  typing indicator clears the moment the field empties, desktop opens
  focused, and drag-drop shows the classic .drop-overlay affordance.

148 JS + 69 Go green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: the chat app is now the default; original feed becomes the opt-out

The flip. Group/DM threads render the chat mini-app full-pane by default —
in the browser and in the native webview alike (a shell that needs the old
feed sets originalChat: true on __lchatEmbed).

- All "lab"/"classic" naming is gone: the previous implementation is the
  ORIGINAL chat everywhere — identifiers (chatAppFor, chatAppNative,
  chatAppFoldMessage, chatAppUpload, chatAppLoadOlder), comments, docs, and
  the localStorage flag, now inverted: "lchat-original-chat" = "1" opts a
  device OUT to the original feed. (The Classic *theme* keeps its name.)
- The ⋮ menu item becomes "Use original chat" / "Use new chat".
- The chat-as-an-app footer badge is gone. The mode indicator is now on the
  ORIGINAL feed, and subtle: its composer placeholder reads
  "Message (original chat)" — visible exactly where you type, no chrome.
- Changelog entry for members: same look and behaviour, new engine, and how
  to switch back if anything seems off.

148 JS + 69 Go green.

Co-Authored-By: Claude <noreply@anthropic.com>

* remind: add notify, preview, and unread hooks for full notification coverage

The reminders app was scheduling fired reminders as encrypted remind.fire
events on the server, but users had no visibility when those events were
delivered — unless the app happened to be open, the reminder would arrive
silently, defeating the purpose of a notification system.

This commit wires the three mini-app framework hooks that give reminders
their "voice":

  - notify(e, ctx): Fires push notifications on all devices (Web Push)
    and toasts on live clients by returning { title, body } for each
    remind.fire event. Looks up the reminder text from app state and
    pairs it with the ⏰ glyph used throughout the app.

  - preview(e): Renders "⏰ Reminder fired" in the sidebar chat list
    preview, so users can spot that their reminders thread has new activity
    without opening the thread. Falls back to the event's body if provided.

  - unread(e): Marks remind.fire events as unread so the thread appears
    to have new messages in the sidebar list, drawing user attention to
    fired reminders.

All three hooks preserve the end-to-end encryption contract: the server
still learns only (thread, time) — the reminder text stays encrypted in
the browser, the notify hook retrieves it from local app state. This
maintains the platform's core property that the server sees no plaintext.

Fixes the user experience gap where reminders could fire and be missed
entirely on closed devices; now alerts reach all members reliably.

* web+mac: post-merge parity ports, Mac confirm() fix, remind hardening

Following the merge of main, port everything main's original feed gained
into the chat app, and fix the Mac-app bug it surfaced:

- Keyboard shortcuts (cmd/ctrl-T and -R, from main): with an empty composer,
  cmd-T opens the message menu on the latest message from someone else and
  cmd-R starts a reply to the latest message. The app's picker now also
  answers the keyboard everywhere it opens: 1–6 toggles that reaction,
  Escape dismisses; Escape also cancels an armed reply/edit when the
  composer isn't focused. One document-level listener dispatches to the
  current mount, so torn-down panes never leak handlers.
- Embed CSS parity: the app feed mirrors .native-embed's safe-area insets,
  and the staged-attachment preview stays visible in the shell (it renders
  just above the native composer — hiding all of .composer-float was wrong).
- "Close poll" was dead in the Mac app: WKWebView resolves confirm() to
  false unless the shell implements the confirm panel (only alert()'s was
  implemented). Fixed on both sides — poll's close is now a two-step inline
  confirm (first click arms, second posts the results; no dialog, works in
  every host), and the Mac shell gains
  runJavaScriptConfirmPanelWithMessage so the remaining confirm() flows
  (message unsend, both feeds) get a real answer.
- remind's new notify/preview hooks (added on the branch) hardened: missing
  folded state or an unknown item no longer throws/goes silent — the
  scheduled event's own encrypted notice is the fallback wording, and
  preview reads the reminder text from the notice.
- Repair two stray NUL bytes in apps.js (a comment and the cleanup-key
  delimiter — functionally benign, byte-level wrong).

Tests: hook-less notice fallback re-anchored to a probe app; new coverage
for remind's item-vs-notice wording. 183 JS + Go green.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: add /board whiteboard mini-app

A shared drawing surface for the current chat: /board opens the panel,
members draw together (5 colors, 3 brush sizes) with strokes folded as
board.stroke events onto the encrypted thread log, same model as poll.
/board clear (or the Clear link) wipes it for everyone. The base canvas
only repaints from folded state; an in-progress local stroke lives on a
separate overlay layer so another member's stroke landing mid-drag can't
erase what you're currently drawing.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: whiteboard close/minimize + fix cross-thread app-panel bugs

- board: add board.close (hides the panel, keeps the drawing — /board
  reopens it) and a local Minimize toggle; ctx is now refreshed every
  render (read from the wrap element, not closed over at build time)
  so drawing always targets the currently active thread.
- chat: #app-surface is one persistent element reused across every
  thread, never rebuilt per-thread. openThread's cached "instant
  restore" path never repainted it on thread switch, so a shared app's
  panel (poll, whiteboard) could keep showing a previous thread's
  content until an unrelated event happened to arrive. Now repaints
  immediately, matching the fresh-load path.
- chat: grantInstanceKeyTo crashed boot() when the roster contained a
  pending invitee (no public key yet) — every other roster consumer
  already filters keyless entries, this one didn't. Reproduced simply
  by inviting someone, not waiting for them to join, and reloading.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: instant-restore app-thread panes so chats don't flash blank on switch

Since chats moved onto the mini-app infrastructure, a group/DM renders
full-pane through the "chat" mini-app (openAppThreadView), which — unlike the
original feed's feedCache instant-restore (24ec129) — had no cache: every open
wiped #app-pane, reset the folded state, and awaited two network fetches before
re-folding and painting. That's the blank flash on every tap into a chat.

Mirror the feedCache pattern for full-pane apps:

- appPaneCache + stashAppPane(): leaving an app-thread parks its rendered root
  and saves the feed's scroll offset (a detach would zero it).
- openAppThreadView gains an instant-restore branch: re-attach the parked root,
  restore scroll BEFORE re-rendering (so the app reads "was at bottom?" from the
  real offset), reconcile in place from the already-folded state — which live WS
  keeps current even for backgrounded chats — then catch up in the background.
  No wipe, no fetch, no blank. First open still builds from history.

Scoped to the reopen flash (the reported regression). A cold first tap into a
never-opened chat still fetches once; prebuilding app-thread state is a separate
follow-up (prebuildVisibleThreads warms feedCache, which this path never reads).

Verified in the browser: switching between two already-opened chats shows the
messages immediately (no blank), holds scroll position, keeps a single pane root
with no duplicated messages, and fires zero /api/messages on a cached reopen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: split out the instance-key roster fix into its own PR

Reverts the grantInstanceKeyTo keyless-filter change from this branch
(kept the app-surface stale-panel fix, needed for the whiteboard) —
the instance-key one touches key-wrapping crypto and gets its own PR
for dedicated review instead of riding in under the mini-apps title.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: route native edit-cancel through the app engine's composer

The native shell's edit-banner cancel button unconditionally called the
classic feed's cancelEdit(), even when the mini-app chat engine (now the
default) was driving the thread. That meant the native ✕ dismissed the
wrong composer's edit state, leaving the app engine's own `editing`
unchanged, and the app engine never told the native shell editing had
started in the first place.

- installNativeBridge()'s cancelEdit now delegates to chatAppNative first,
  matching the existing cancelReply/sendText/typing/stage/didAttach pattern.
- apps/chat.js registers a cancelEdit native handler and has its
  startEdit/cancelEdit post {editing: {...}} / {editing: null} back to the
  shell, mirroring the existing startReply/cancelReply wiring.

* web: port DM typing bubble and force-aware didAttach to the app chat engine

The classic feed (0706d59) shows an animated iMessage-style typing bubble
(three pulsing dots + a screen-reader label) in DMs, only rebuilding the
row's DOM when the mode or label changes so the dot animation doesn't
restart on every ~10s typing beat. The app engine's drawTyping() never
got this — it always rendered a plain "X is typing…" text row. Port the
same dm check (host.isGroup), markup, and rebuild-on-change guard.

Also make the native bridge's didAttach() accept a `force` flag and only
snap to the bottom when forced (notification-tap reattach) or the reader
was already at the bottom, matching the classic feed's didAttach(force).
Previously it unconditionally reset scrollTop on every reattach, blowing
away a deliberate scroll-up whenever the native shell foregrounded or
switched back to a parked webview.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: port GPU-smooth lightbox + swipe-reply to the chat-app engine

The classic feed (web/static/chat.js) got a full lightbox rewrite and a
swipe-to-reply smoothness pass in 2e2d493/6331bea — RAF-batched
translate3d transforms, a separate opacity-dimmed backdrop, pinch-zoom
with free rotation, double-tap zoom, pan, flick-to-dismiss, and a FLIP
fly-in/out from the tapped thumbnail. Neither change touched the newer
chat-as-an-app engine (web/static/apps/chat.js), which is now the
default most users see, so its lightbox was left as a bare stub with no
animation at all (the CSS that used to animate open/close was removed
from style.css in favor of the JS-driven FLIP math) and its swipe-reply
still wrote transform on every touchmove instead of batching through
requestAnimationFrame.

Port both wholesale into apps/chat.js so the two feeds stay identical:
openLightbox(url, source) is a near-verbatim copy (self-contained, no
classic-only globals); its one call site now passes the thumbnail img
so the FLIP animation has a real rect to fly from/to. attachSwipeReply
keeps its existing touch-event wiring and composers.get(...).startReply
trigger, with the render path RAF-batched onto translate3d and
will-change toggled for the duration of the drag.

Co-Authored-By: Claude <noreply@anthropic.com>

* changelog: chat-app engine parity fixes

Co-Authored-By: Claude <noreply@anthropic.com>

* web: port AI/BOT helper badge kind-awareness to the app chat engine

main split the BOT badge into AI/BOT (kind-specific) and centered it on a
flex name line; the app-engine feed had its own agentTag() duplicate that
was still boolean-only. Add host.agentKind(name) alongside isGroup so the
app engine gets the same "ai" vs "bot" distinction, then port the badge
markup/CSS verbatim.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: let the chat mini-app handle pending (not-yet-joined) DMs too

chatAppFor() special-cased pending DMs back to the classic feed, since the
waiting-card CSS (.thread-view.waiting) only hid .feed/.composer, not the
app engine's .app-pane. That was the last place a DM/group thread could
still fall back to the classic renderer outside the manual "Use original
chat" toggle — closing it so the classic renderer isn't load-bearing for
any chat behavior, just an opt-out.

Add .app-pane to the waiting-hide list and drop the pendingInvitee() guard
in chatAppFor(); the pending banner still mounts/clears exactly as before
(updatePendingBanner is unchanged), it now hides the app engine's pane
instead of the classic feed. Verified live: a pending DM shows app-mode +
waiting classes together, .app-pane is display:none with .chatapp-root
mounted underneath it, banner visible, no console errors.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: explain the pending-DM invite flow and why it stays out of the app model

New §5 in the mini-apps architecture doc traces inviting a brand-new person
straight into a DM end to end (POST /api/invites → placeholder user + dm
thread + thread_keys row for the inviter only → pendingInvitee() → waiting
card instead of a feed) and explains why it's a shell-level concern rather
than a system app: it's a live-derived roster fact, not replicated event-log
state, and it has to suppress whichever pane is mounted underneath it
(classic feed or the chat app's .app-pane) rather than live inside one of
them. Documents the recent .thread-view.waiting/.app-pane fix and notes the
deferred follow-on (move this into the chat app's own render via a
host.pendingInvitee fact, once the classic feed is retired and there's only
one engine left to keep in sync with).

Cross-referenced from apps/README.md's scope section.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: chat-parity fix stack — all 25 fixes (app-vs-original parity + review) (#169)

* web: suppress native touch selection on the app chat feed

The coarse-pointer rule that kills native text-selection and the iOS
copy callout so a long-press opens the message menu cleanly was gated on
`.feed .bubble`, which the newer app engine's `.chatapp-feed` markup
never matched. Extend the selector to also cover `.chatapp-feed .bubble`
so long-press works the same in the app feed. Desktop (fine pointer) is
untouched and keeps native selection.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: date legacy chat messages from created_at in the app fold

Stored rows predating ts-stamping have no inline `ts` in their decrypted
payload. `chatAppFoldMessage` folded them with `ts: data.ts`, which collapses
to 0 in the app reducer (`e.ts || meta.ts || 0`), so they rendered at Jan 1
1970 and spawned a spurious "January 1970" day separator.

Pass the server's `created_at` (seconds → ms) as the fold meta ts, mirroring
the original render path's `data.ts || m.created_at * 1000` fallback. Only the
history/scroll-back/reconnect fold path needs this; live messages always carry
`ts`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: keep keyboard shortcuts live after an instant chat restore

The chat engine's desktop keyboard shortcuts (Cmd/Ctrl-R reply, Cmd/Ctrl-T
message menu, Escape to cancel an armed reply) are driven by one document-level
keydown listener that dispatches to a single module-level `hotkeys` handler.
That handler was only ever assigned inside `buildSkeleton`, which `render()`
skips whenever a `.chatapp-root` already exists — exactly what the app-pane
cache's instant-restore path does when you re-open a previously-opened thread.
So after opening chat A, then chat B, then clicking back to A, `hotkeys` still
pointed at B's now-parked (detached) root and its `isConnected` guard no-oped:
shortcuts were dead on every chat thread except the last one to run a first
build.

Make `render()` the single writer of the dispatcher: `buildSkeleton` stashes
its key handler on the root (`root._hotkeys`) and `render()` re-points the
module `hotkeys` at the active mount on every call, not just first build. The
assignment is gated on `root.isConnected` so a parked thread re-rendering
off-screen can never steal the visible pane's shortcuts.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: restore the labelled long-press message menu on touch

The chat mini-app engine had only ported the compact desktop-style picker
(icon-only actions, no backdrop, no labels) to touch. This restores parity with
the original feed's touch UX:

- Long-press now opens the labelled sheet: a dim backdrop, the pressed message
  lifted above it, an emoji reaction row floating above, and a labelled
  Reply/Copy/Edit/Delete menu below. Edit/Delete gate to your own messages
  (Edit only within the edit window), matching the original.
- Double-tap now opens the emoji-only reactions bar, not the full picker.

Reuses the original's .msg-sheet CSS (no new styles) and mirrors
openMessageSheet / openReactionBar / attachReactionGesture from chat.js. Desktop
(hover strip + press-and-hold compact picker) is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: recognise your own legacy messages by name in the app chat

The new mini-app chat attributed messages by inline uid only (`m.uid ===
ctx.me`), with no screen-name fallback. Stored rows you authored before we
started stamping uid into the payload have no uid, so they rendered as
someone else's — left-aligned, your name shown in group runs, and not
editable — while the original feed (`ownMessage`, which falls back to a
name match) has always treated them as yours.

Thread your session screen name into the app context (`meName`) and reuse
`ownMessage` for attribution in the app chat: the feed alignment/name, the
desktop hover Edit, the touch long-press Edit/Delete, and the up-arrow
edit-last now all fall back to a name match, matching the original feed.

The screen name already lives client-side in the session; nothing new is
sent to the server, so the end-to-end-encryption invariant is preserved.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: keep the composer text when a send can't reach the server

The mini-app chat composer cleared the input, dropped staged attachments, and
reset the reply target the instant you pressed Enter/Send — even when the
WebSocket was down or reconnecting, in which case the message was never sent.
The typed text was silently lost with no way to recover it. The original
(classic) composer never did this: it checks the socket up front and bails
without clearing, so you can resend once you're back online.

ctx.emit already resolves to false when the socket is down (the transport
guards ws.readyState), and host.edit does the same. So only clear the composer
once the send/edit actually reaches the server — wait for the resolved value
and keep the text, staged files, and reply target intact on failure. The
connected happy-path is unchanged (the text clears as soon as encrypt resolves).

Also grey the mini-app send button while offline, mirroring the classic
composer: chat.js now tells the app on ws open/close via a small setConnected
export, and refreshSend disables the button while disconnected.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: use the short month in app chat day separators

The newer app chat engine's dayLabel formatted dated day separators with
the long month ("July 4"), diverging from the classic chat's short month
("Jul 4"). Mirror the original's toLocaleDateString options so every dated
separator in a multi-day conversation matches again.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: don't record a reaction locally when it can't reach the server

Tapping a reaction while the WebSocket is down/reconnecting flipped the
local mine-flag in the chat mini-app even though the send no-oped on the
closed socket, so a later re-render/reconnect showed the wrong reaction
state.

host.react now returns whether the frame actually went on the wire (a
boolean, matching the edit/blob-delete transports), and the app's
optimistic toggleReaction only mutates myReactions once host.react
reports success.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: render your own @mention as plain text, not a fake link

The newer chat engine (web/static/apps/chat.js) added the tappable
.mention-link affordance (cursor:pointer, hover underline, role="button",
tabIndex) to every @mention whose name resolved in the roster — including a
mention of your OWN name. But host.mention() no-ops for self, so the chip
looked and behaved like a link that did nothing on click.

The classic feed's buildMention gates that affordance on
`entry.user_id !== myUserId`, leaving a self-mention a plain bold chip. Mirror
that: expose a host.mentionable(label) predicate (same roster + myUserId gate)
and only add the link/menu when the mention resolves to another member.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: keep the app feed pinned to the newest message as the composer grows

While reading at the bottom of the feed, typing a message that wraps to
more lines — or starting to edit a longer one — grew the composer/banner
and drifted the feed upward, pushing the newest message out of view. The
classic chat.js re-pins on composer growth (a ResizeObserver plus an
at-bottom re-pin in the autogrow path); the mini-app engine had none.

Add a ResizeObserver on the feed itself. Because the composer, its
reply/edit banner, and the attachment preview are in-flow flex siblings,
any of them growing shrinks the feed and fires the observer, which
re-pins to the bottom — but only if the reader was already there. "At
bottom" is tracked from real scrolls (matching render()'s `< 60` check)
so the decision reflects the pre-resize state, and the pin is re-asserted
next frame to survive WebKit restoring the old scrollTop after observers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: restore specific attachment error messages in the app composer

The chat mini-app's composer collapsed every attachment failure to one
generic "Couldn't add that file." and, for a disallowed file type, briefly
flashed a loading chip before the alert. Restore the original composer's
three distinct messages (photo-too-big / file-too-big-with-limit /
unsupported-type) and reject unsupported types before staging so no chip
appears.

The host bridge (chatAppUpload) now returns a reason string
("type" / "photo" / "size" / "generic") instead of a bare null, and
stageFiles maps each to the right wording. The extension allow-list is
also checked in the app before the pending tile is pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: keep the typing indicator alive through mid-message pauses

The chat mini-app only emitted a typing beat on keystrokes (throttled once
per 4s, 8s expiry), so pausing mid-message with text still in the composer
let the receiver's indicator lapse a few seconds later — unlike the original
composer, which re-broadcasts on an interval and expires beats generously.

Mirror the original's cadence: a render-scoped ticker (tickTyping) re-emits a
beat every TYPING_INTERVAL_MS (10s) while there's text and recent keystrokes,
with each beat carrying TYPING_EXPIRY_S (15s) so a single one outlives the
interval; going idle, emptying the field, or sending fires an explicit
expiry-0 stop. The beat now emits once per burst rather than per keystroke
(the old throttle's intent), and own-echo suppression is unchanged (filtered
by name at display time).

The ticker lives on the render path (next to drawTyping), not in
buildSkeleton: the framework clears every registered interval before each
render, so a once-per-mount timer would be torn down and never re-armed.
State lives in the composer control, keyed off absolute timestamps, so
re-registering the interval each draw is harmless.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: let the native shell handle file drops in the app chat

The chat mini-app registered dragenter/dragover/drop listeners on its root
unconditionally. In the native macOS wrapper (Eleven) that makes WebKit claim
the Finder file-drop, bypassing the native path that actually uploads it — so
nothing happened. Mirror the classic chat.js guard: skip registering the web
drop listeners (and their overlay affordance) when html.native is set, letting
the native shell intercept the drop as it does for the original chat page.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: continue blockquotes when pasting into the app composer

The app chat engine's paste handler only inspected clipboardData.files
and left text pastes to the browser default, so pasting multi-line text
onto a "> " line dropped the quote after the first line. The classic
composer (web/static/chat.js) already continues the blockquote by
prefixing each subsequent pasted line with "> "; port that behaviour
verbatim into the app composer's paste handler, kept as a separate text
branch so the attachment/staging (files) branch stays untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: match the original swipe-reply axis-lock and reset on touchcancel

The mini-app chat's attachSwipeReply diverged from the original feed's
swipe-to-reply in two edge cases:

- Axis-lock: the app entered swipe on any dx>12 with dy<=12, so a diagonal
  drag the original treats as a vertical scroll would trigger a reply. Mirror
  the original's ratio-based lock — horizontal must dominate vertical by ~1.3×.
- touchcancel: the app listened only for touchstart/move/end, so a swipe
  interrupted by the system (call, notification) fired no touchend and left
  the bubble stuck mid-transform. Route touchend and touchcancel through one
  onEnd path so an interrupted swipe resets; only a real release replies.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: show the URL port and retry failed link previews in app chat

Two link-preview regressions in the mini-apps chat engine, both diverging
from the original feed's behaviour:

- The host label used `new URL(url).hostname`, dropping an explicit
  non-default port (example.com:8080 → example.com). Switched to `.host`
  to match the original.
- A failed/empty unfurl was cached permanently in the module-level
  `unfurls` map (the `.catch(() => null)` result and any resolved
  null/{ok:false} stuck forever), so a transient failure blanked the
  preview until a full page reload. Now an unusable result is evicted so
  a later render (e.g. reopening the thread) refetches — matching the
  original, which never cached and refetched every time. In-flight
  de-duplication for concurrent renders is preserved; only the settled,
  unusable result is un-cached. A usable preview whose bubble was torn
  down keeps its cache entry.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: don't lose app state when live events race the snapshot load

openThread sets activeId=id then `await seedAppState(id)`. A live app event
for that thread arriving during the snapshot-load await folded into store[id]
first, so seedThread's old `if (store.has(threadId)) return` guard skipped
installing the snapshot. The truncated state was then persisted at an advanced
horizon (maxFolded had moved to the interim id), and on the next open every
pre-horizon event was skipped — history older than the message window (todos,
poll/board history, bookmarks) was lost and never self-healed.

Fix: the snapshot at `upTo` is the authoritative floor, so seedThread now
always reinstalls it as the base and clears the forward-fold bookkeeping
(foldedIds/maxFolded/dirty). Below-horizon interim folds are superseded by the
snapshot; above-horizon interim events are restored by the caller's catch-up
(seedAppState re-reads everything after upTo right after seeding). Clearing the
dedupe set is safe because reducers are idempotent by entity id (Invariant #1),
so the re-fold converges. Clearing maxFolded + dirty closes the second half of
the bug: a debounced save firing between seed and catch-up can no longer anchor
base-only state at the interim horizon.

apps.js-only: the existing catch-up in seedAppState already covers restoring
the interim events, so chat.js needs no change. Reworks the old "no-op once the
thread has live state" test to assert the corrected (authoritative-floor)
semantics and adds a regression test for the race (both fail against the old
guard).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: make the poll panel incremental so Close survives live votes

The two-step "Close poll" confirm kept its armed state only on the button
(close.dataset.armed), but the poll surface had no `incremental` flag — so the
framework wiped and rebuilt the whole panel on every folded event (apps.js). Any
vote or chat message landing between the creator's two clicks discarded the armed
button, so on an actively-voted poll the creator could rarely close it or post
results — exactly when they'd want to.

Give the poll surface the same `incremental: true` treatment board.js uses: each
poll card is built ONCE (keyedList reconciles which cards exist and their order,
never rebuilding a survivor) and its live figures — fill bars, tallies, the
"mine" highlight, the total — are refreshed in place by updateCard on every fold.
The Close button is therefore never torn out, so an armed confirm rides through
intervening votes/messages. Handlers read card._ctx / card._poll (refreshed each
render) rather than a build-time capture, so a vote/close targets the active
thread and the posted summary counts every vote right up to the close.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: keep the native bridge pointed at the active chat after restore

host.registerNative() sets a SINGLE native-composer dispatcher in the host
(chat.js: chatAppNative), and the chat app called it only from buildSkeleton.
On the instant-restore path buildSkeleton is skipped, so after you open A, open
B, then revisit A, the bridge was still pointed at B's (now-detached) handlers —
a native send/typing/attach routed to the wrong thread.

Mirror the #143 hotkeys fix: stash the native handlers on root._native in
buildSkeleton and re-point the host's dispatcher from render() on every open
(including restore), guarded on root.isConnected so a parked pane re-rendering
off-screen can't grab the bridge. The host contract already overwrites the
single dispatcher on each call, so no chat.js change is needed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: never show your own typing indicator in the app chat

The `chat.typing` ephemeral hook had no self guard: it relied on the
display-time `typingNames(threadId, myName())` filter, but `myName()` is
null until one of your own messages folds into the window. In a fresh
DM/group (or after your last message ages out) your relayed typing beat
echoed back at you as if a member were typing.

Drop your own beat in the hook instead: match by uid (robust regardless of
whether any of your messages are in view) and fall back to the screen name
for uid-less legacy beats — mirroring the original feed's
`payload.name === session.name` guard, but disambiguating same-named users.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: update reactions in place instead of rebuilding the whole bubble

Paul review #3: `vrev` folded `m.rev` (which bumped on every reaction) and the
reply tally into one key, so keyedList did a full `node.replaceWith(render(item))`
on any reaction or incoming reply. That recreated the bubble's `<img>` (re-awaiting
loadBlob — a visible flicker) and reverted an expanded, playing YouTube card to
its poster, stopping playback. The original feed only ever repainted the reactions
row / reply tally in place.

Split the composite rev into a HARD `vrev` (content + neighbour layout: a change
rebuilds the bubble) and a SOFT `srev` (reactions + reply tally: a change patches
those rows in place). `chat.react` no longer bumps `rev`; `chat.edit` still does,
so a genuine text edit re-renders. keyedList gains an optional `srev`/`update(node,
item)` path: when only the soft rev changed it calls `update` on the retained node
instead of replacing it. chat's `patchBubble` reconciles only the `.reactions` row
and `.reply-count` — both siblings after `.content` — and never descends into
`.content`, so the decrypted photo and the live YouTube iframe survive untouched.
The "mine"-flag seed moves into the shared `buildReactionsRow` so both the initial
render and the in-place patch seed it (add-only, idempotent).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: skip the redundant classic-feed decrypt for chat-app threads

An incoming message on a chat-app thread is already folded and drawn via
renderActiveSurfaces(); the ws handler then still called appendMessage,
which re-decrypted the same blob and painted a phantom bubble into the
hidden classic #feed. Guard that classic append with !chatAppFor(tid) so
it is skipped for chat-app threads, removing a per-message AES-GCM decrypt
and the phantom DOM.

The app fold/draw is untouched (fold at line 4492, renderActiveSurfaces at
4494). Original-feed clients (chatAppFor null, including useOriginalChat)
still get the real appendMessage. All other side effects stay outside the
gate: unread + bumpThread (else branch), preview/last_at/maybeNativeNotify,
renderThreadList, and updatePendingBanner.

Fixes Paul review #6.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* server: surface real DB errors when cancelling a scheduled message

scheduledMessageThread treated any Scan error (not just sql.ErrNoRows) as
"not found", so deleteSchedule fell through to 200 {ok:true} without deleting
the row. On a real DB failure the client believed the reminder was cancelled
while the row survived and the reminder still fired.

Distinguish sql.ErrNoRows (legitimately absent -> found=false, no error) from
any other error (propagate it). deleteSchedule now returns 500 on a genuine DB
failure so the client learns the cancel didn't take; a missing row still
succeeds quietly, as cancelling an already-fired/absent schedule is not an error.

Adds TestScheduleCancelDBError, which forces a non-ErrNoRows lookup error and
asserts a 500 rather than a false 200.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: validate /remind absolute times and stop the duplicate offline toast

parseAbsolute silently let JS Date roll over out-of-range parts (2026-06-31
became Jul 1, 24:00 became the next day), unlike nextClockTime which rejects
them. Add the same rigor: range-check month/day/hour/minute, then confirm the
constructed Date kept every field (catching day-past-month-end and off-leap
Feb 29). Bad dates/times now return the same null the caller already handles.

The offline-fallback fire() showed a local system Notification AND emitted
remind.fire, whose echo drove notify → notifyLive into a second, duplicate
alert on the firing device. Record the id in a device-local set when we show
the local Notification and have notify() swallow that one echo. A shared-uid
check couldn't do this (personal app — every device shares the uid); other
devices never populate the set, so they still get alerted.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: confirm Copy with the same toast the original chat shows

The chat mini-app's Copy action (both the desktop press-and-hold picker and
the touch long-press sheet) wrote the message text to the clipboard silently
and swallowed failures — no feedback either way. The classic feed's
copyMessageText awaits the write and shows a status pill ("Copied" /
"Couldn't copy"); the app now matches it.

Both handlers become async and await the clipboard write so the catch actually
sees a rejection, then confirm through a new minimal host.toast bridge method
that reuses the classic feed's toast — no new toast pattern, just the same
confirmation the original chat has always shown.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam d704987b73d9167388a5e10504430864ea46cf4c parent cb03956
53 files changed, +8098 −25
  • CLAUDE.md +7 −0
  • changelog/2026-07-02-todo-mini-app.md +8 −0
  • changelog/2026-07-03-chat-app-parity.md +1 −0
  • changelog/2026-07-03-fix-stale-app-panel.md +1 −0
  • changelog/2026-07-03-new-chat-engine.md +1 −0
  • changelog/2026-07-03-no-blank-flash-on-switch.md +1 −0
  • changelog/2026-07-03-whiteboard-mini-app.md +1 −0
  • changelog/2026-07-04-chat-app-badge-parity.md +1 −0
  • changelog/2026-07-04-fix-attachment-error-messages.md +1 −0
  • changelog/2026-07-04-fix-chat-keyboard-shortcuts.md +1 −0
  • changelog/2026-07-04-fix-composer-repin.md +1 −0
  • changelog/2026-07-04-fix-copy-confirmation.md +4 −0
  • changelog/2026-07-04-fix-day-separator-month.md +1 −0
  • changelog/2026-07-04-fix-legacy-message-dates.md +1 −0
  • changelog/2026-07-04-fix-legacy-own-messages.md +1 −0
  • changelog/2026-07-04-fix-link-preview-parity.md +1 −0
  • changelog/2026-07-04-fix-native-drop-guard.md +1 −0
  • changelog/2026-07-04-fix-native-thread-routing.md +6 −0
  • changelog/2026-07-04-fix-own-typing-indicator.md +1 −0
  • changelog/2026-07-04-fix-paste-blockquote.md +1 −0
  • changelog/2026-07-04-fix-poll-close-during-voting.md +1 −0
  • changelog/2026-07-04-fix-react-no-bubble-rebuild.md +1 −0
  • changelog/2026-07-04-fix-reaction-while-offline.md +1 −0
  • changelog/2026-07-04-fix-remind-validation.md +5 −0
  • changelog/2026-07-04-fix-reminder-cancel-error.md +1 −0
  • changelog/2026-07-04-fix-self-mention-styling.md +1 −0
  • changelog/2026-07-04-fix-send-while-offline.md +1 −0
  • changelog/2026-07-04-fix-swipe-reply-parity.md +1 −0
  • changelog/2026-07-04-fix-touch-longpress-selection.md +1 −0
  • changelog/2026-07-04-fix-typing-rebeat.md +1 −0
  • changelog/2026-07-04-perf-skip-classic-append.md +1 −0
  • changelog/2026-07-04-touch-message-menu-parity.md +6 −0
  • db.go +212 −0
  • docs/mini-apps-architecture.html +394 −0
  • hub.go +16 −0
  • ios/LChatMac/ChatFeedWebView.swift +14 −0
  • main.go +12 −0
  • miniapps.go +258 −0
  • miniapps_test.go +321 −0
  • package.json +1 −1
  • test/apps.test.mjs +884 −0
  • web/pages/index.html +7 −0
  • web/static/apps.js +695 −0
  • web/static/apps/README.md +586 −0
  • web/static/apps/board.js +347 −0
  • web/static/apps/bookmarks.js +222 −0
  • web/static/apps/chat.js +2491 −0
  • web/static/apps/poll.js +249 −0
  • web/static/apps/remind.js +386 −0
  • web/static/apps/todo.js +126 −0
  • web/static/chat.js +791 −14
  • web/static/style.css +5 −2
  • web/static/sw.js +19 −8
CLAUDE.md

Loading diff…

changelog/2026-07-02-todo-mini-app.md

Loading diff…

changelog/2026-07-03-chat-app-parity.md

Loading diff…

changelog/2026-07-03-fix-stale-app-panel.md

Loading diff…

changelog/2026-07-03-new-chat-engine.md

Loading diff…

changelog/2026-07-03-no-blank-flash-on-switch.md

Loading diff…

changelog/2026-07-03-whiteboard-mini-app.md

Loading diff…

changelog/2026-07-04-chat-app-badge-parity.md

Loading diff…

changelog/2026-07-04-fix-attachment-error-messages.md

Loading diff…

changelog/2026-07-04-fix-chat-keyboard-shortcuts.md

Loading diff…

changelog/2026-07-04-fix-composer-repin.md

Loading diff…

changelog/2026-07-04-fix-copy-confirmation.md

Loading diff…

changelog/2026-07-04-fix-day-separator-month.md

Loading diff…

changelog/2026-07-04-fix-legacy-message-dates.md

Loading diff…

changelog/2026-07-04-fix-legacy-own-messages.md

Loading diff…

changelog/2026-07-04-fix-link-preview-parity.md

Loading diff…

changelog/2026-07-04-fix-native-drop-guard.md

Loading diff…

changelog/2026-07-04-fix-native-thread-routing.md

Loading diff…

changelog/2026-07-04-fix-own-typing-indicator.md

Loading diff…

changelog/2026-07-04-fix-paste-blockquote.md

Loading diff…

changelog/2026-07-04-fix-poll-close-during-voting.md

Loading diff…

changelog/2026-07-04-fix-react-no-bubble-rebuild.md

Loading diff…

changelog/2026-07-04-fix-reaction-while-offline.md

Loading diff…

changelog/2026-07-04-fix-remind-validation.md

Loading diff…

changelog/2026-07-04-fix-reminder-cancel-error.md

Loading diff…

changelog/2026-07-04-fix-self-mention-styling.md

Loading diff…

changelog/2026-07-04-fix-send-while-offline.md

Loading diff…

changelog/2026-07-04-fix-swipe-reply-parity.md

Loading diff…

changelog/2026-07-04-fix-touch-longpress-selection.md

Loading diff…

changelog/2026-07-04-fix-typing-rebeat.md

Loading diff…

changelog/2026-07-04-perf-skip-classic-append.md

Loading diff…

changelog/2026-07-04-touch-message-menu-parity.md

Loading diff…

db.go

Loading diff…

docs/mini-apps-architecture.html

Loading diff…

hub.go

Loading diff…

ios/LChatMac/ChatFeedWebView.swift

Loading diff…

main.go

Loading diff…

miniapps.go

Loading diff…

miniapps_test.go

Loading diff…

package.json

Loading diff…

test/apps.test.mjs

Loading diff…

web/pages/index.html

Loading diff…

web/static/apps.js

Loading diff…

web/static/apps/README.md

Loading diff…

web/static/apps/board.js

Loading diff…

web/static/apps/bookmarks.js

Loading diff…

web/static/apps/chat.js

Loading diff…

web/static/apps/poll.js

Loading diff…

web/static/apps/remind.js

Loading diff…

web/static/apps/todo.js

Loading diff…

web/static/chat.js

Loading diff…

web/static/style.css

Loading diff…

web/static/sw.js

Loading diff…