eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// Mini-apps: small client-side programs that trade typed events over the same
2// end-to-end-encrypted thread stream chat uses. The server only ever sees
3// ciphertext, so it can't route or run app logic — an app lives entirely in the
4// browser and derives its state by folding the thread's event stream. In other
5// words: the encrypted thread log IS the app's database.
6//
7// An app is four small things:
8// - an events map — `{ "todo.add": (state, event) => state }`. Its KEYS are the
9// event types the app owns (`todo.add`), carried as `type` inside the payload
10// exactly like a chat message's type (old clients that don't know the app just
11// ignore them); its VALUES fold each event into new state. One declaration is
12// the whole model — the owned types and the reducer are both derived from it,
13// so there's no separate `types` array to keep in sync.
14// - an initialState factory — `() => ({ items: [] })`, the empty state.
15// - commands it registers (`/todo …`) that emit events.
16// - a surface where its state renders (full-pane for a personal app, a panel
17// above the feed for a shared one; plus an optional inline stream line).
18//
19// State is folded from the messages currently loaded for a thread (the "window"
20// the client already holds). That's enough to prove the model; a later snapshot
21// blob can make state survive reload/late-join without re-folding history — the
22// reducer shape here is deliberately snapshot-ready.
23
24import { BOT_COMMANDS } from "./bot-commands.js";
25import { parseMessage, renderTree } from "./markdown.js";
26
27const apps = [];
28const typeOwner = new Map(); // event type → owning app
29const commandOwner = new Map(); // "/todo" → owning app (app commands; bot commands live in BOT_COMMANDS)
30
31// injectStyles adds a <style> once, keyed so re-registration never duplicates
32// it. This is how an app ships its own CSS *inside its own file* — no edits to
33// the shared stylesheet, so a mini-app is a single self-contained module. Runs
34// only in the browser (guarded so apps.js stays importable in Node tests).
35function injectStyles(key, css) {
36 if (!css || typeof document === "undefined") return;
37 const id = "app-style-" + key;
38 if (document.getElementById(id)) return;
39 const el = document.createElement("style");
40 el.id = id;
41 el.textContent = css;
42 document.head.appendChild(el);
43}
44
45// The framework's own chrome: the panel mount + per-app section, and the generic
46// stream line. Everything app-specific ships in the app's own `styles`. Injected
47// on load so nothing mini-app-related has to live in the shared style.css.
48injectStyles(
49 "framework",
50 `.app-surface { display: flex; flex-direction: column; flex: 0 0 auto; }
51.app-panel {
52 padding: 10px 16px; border-bottom: 1px solid var(--border);
53 background: var(--panel); max-height: 40vh; overflow-y: auto;
54}
55.app-panel:empty { display: none; }
56.app-line { align-self: center; font-size: 12px; color: var(--muted); padding: 4px 0; text-align: center; }
57/* Personal app-thread: the app renders full-pane instead of the chat feed.
58 The hides target the CLASSIC elements only (direct children of the thread
59 view), so an app is free to reuse the same class names — e.g. the chat app
60 builds a .feed/.composer of its own inside the pane and inherits the original feed's
61 stylesheet wholesale. */
62.app-pane { display: none; }
63.thread-view.app-mode > .feed,
64.thread-view.app-mode > .app-surface,
65.thread-view.app-mode > .composer,
66.thread-view.app-mode > .composer-float,
67.thread-view.app-mode > .typing,
68.thread-view.app-mode > .typeahead,
69.thread-view.app-mode > .composer-reply,
70.thread-view.app-mode > .composer-edit,
71.thread-view.app-mode > .drop-overlay { display: none !important; }
72.thread-view.app-mode .app-pane {
73 display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
74 overflow-y: auto; padding: 16px; gap: 10px;
75}
76/* A command's transient results panel (see openCommandPanel): in-flow chrome
77 above the composer (default) or above the feed (.cmd-panel--top), occupying
78 layout space — never floating over content (CLAUDE.md). */
79.cmd-panel {
80 flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0;
81 background: var(--panel); border-top: 1px solid var(--border);
82 box-shadow: 0 -6px 18px var(--shadow); /* a subtle lift off the feed above */
83}
84.cmd-panel--top {
85 border-top: none; border-bottom: 1px solid var(--border);
86 box-shadow: 0 6px 18px var(--shadow); /* pinned at the top — cast downward */
87}
88.cmd-panel-head {
89 display: flex; align-items: center; gap: 6px; padding: 8px 14px 0;
90 font-size: 12px; color: var(--muted);
91}
92.cmd-panel-title { font-weight: 600; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
93.cmd-panel-extra { flex: 0 0 auto; display: flex; align-items: center; }
94.cmd-panel-x {
95 flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
96 border: none; background: none; color: var(--muted);
97 font-size: 16px; line-height: 1; padding: 4px; cursor: pointer; border-radius: 6px;
98}
99.cmd-panel-x svg { width: 16px; height: 16px; display: block; }
100.cmd-panel-x:hover { color: var(--ink); background: var(--bubble-them); }
101.cmd-panel-body { padding: 8px 14px 10px; max-height: 34vh; overflow-y: auto; }
102/* The side placement: a full-height right column — a vertical sidebar for
103 list-shaped panels (views). The root reserves the gutter with padding, so
104 the column occupies real layout space (nothing floats over content); on
105 narrow screens it falls back to the in-flow bottom band. */
106@media (min-width: 721px) {
107 .chatapp-root.has-side-panel { padding-right: min(340px, 40%); }
108 .cmd-panel--side {
109 position: absolute; top: 0; right: 0; bottom: 0; width: min(340px, 40%);
110 border-top: none; border-left: 1px solid var(--border);
111 box-shadow: none;
112 }
113 .cmd-panel--side .cmd-panel-head { padding-top: 12px; }
114 .cmd-panel--side .cmd-panel-body { max-height: none; flex: 1 1 auto; }
115}
116/* An app's glyph avatar (sidebar + header) and the "app" badge on its row. */
117.app-avatar { display: flex; align-items: center; justify-content: center; background: var(--bubble-them); font-size: 18px; }
118/* A vendored brand logo inside an avatar or notice glyph slot (appGlyphInto):
119 sized to sit like the emoji it replaces, inked like text. */
120.app-logo svg { width: 55%; height: 55%; display: block; color: var(--ink); }
121.local-notice-glyph.app-logo svg { width: 14px; height: 14px; }
122.app-badge {
123 font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
124 color: var(--muted); background: var(--bubble-them); border-radius: 6px;
125 padding: 1px 5px; margin-left: 6px; vertical-align: middle;
126}
127/* The space band's row badge: the same pill, tinted to the accent so a space
128 home reads differently from a personal app-thread at a glance — but the
129 word "space" carries the meaning (state is never color alone). */
130.app-badge.space-badge { color: var(--accent-ink, #fff); background: var(--accent); }
131/* A space copy's tile pairs the SPACE's photo with the app's glyph as EQUAL
132 halves — two same-sized circles, photo upper-left, glyph lower-right,
133 overlapping on the diagonal — so the space row and the personal app-thread
134 stop being visual twins, and neither identity reads as subordinate.
135 Reinforcement only: the row's title, badge, and resting line carry the
136 state in words. */
137.space-app-avatar { position: relative; background: none; }
138.space-app-photo {
139 position: absolute; top: 0; left: 0; width: 65%; height: 65%;
140 border-radius: 50%; background-size: cover; background-position: center;
141}
142.space-app-glyph {
143 position: absolute; right: 0; bottom: 0; width: 65%; height: 65%;
144 display: flex; align-items: center; justify-content: center; font-size: 12px;
145 background: var(--bubble-them); border: 1px solid var(--border); border-radius: 50%;
146}
147/* Place badges — where an app copy lives ("This chat" / "Everyone" / "Yours";
148 the places grammar, docs/space-apps.md). Same pill as .app-badge: the words
149 carry the meaning, colour identity can come with a later design pass. */
150.place-badge {
151 font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
152 color: var(--muted); background: var(--bubble-them); border: 1px solid var(--border);
153 border-radius: 6px; padding: 1px 5px; margin-left: 6px; vertical-align: middle; white-space: nowrap;
154}
155/* The one-time orientation card atop a shared app's Interface pane: says WHERE
156 this copy lives. In-flow (occupies layout space, never floats — CLAUDE.md),
157 dismissed once per copy. .group-app-root is the app's own container under
158 it, so an app's render can never wipe the host's card. */
159.place-card {
160 flex: 0 0 auto; display: flex; align-items: baseline; gap: 6px;
161 font-size: 12.5px; color: var(--muted); background: var(--bubble-them);
162 border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px;
163}
164.place-card b { color: var(--ink); font-weight: 600; white-space: nowrap; }
165.place-card span { flex: 1 1 auto; }
166.place-card button {
167 border: none; background: none; color: var(--muted); font-size: 14px; line-height: 1;
168 cursor: pointer; padding: 0 2px; border-radius: 4px; flex: 0 0 auto; align-self: center;
169}
170.place-card button:hover { color: var(--ink); }
171.group-app-root { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }`,
172);
173
174// namespace(ns) is the one-declaration helper an app uses to derive every string
175// it prefixes with its own name — so the prefix lives in ONE place and can never
176// drift (Invariant #4: event types are globally unique, namespaced by app). It
177// returns a tiny resolver:
178// N.ev("add") → "todo.add" — an event TYPE (joined with ".", the wire string
179// that crosses to native — see the event-vector
180// contract; the helper only spells it, it does
181// not change it)
182// N.cls("item")→ "todo-item" — a CSS CLASS name (joined with "-")
183// Use it for the `events` map keys (computed: `[N.ev("add")]: …`), for `ctx.emit`
184// types, for `e.type === N.ev("add")` comparisons, and for className / selector
185// strings in render. `ev` passes a name that already carries a "." through
186// untouched, so a full type ("chat.msg") or another app's type still resolves —
187// which keeps migration a safe find-and-replace. Pure + exported so it's unit
188// tested without a DOM, and mirrored in the sandbox SDK (sandbox-frame.js) so a
189// member app gets the identical helper.
190export function namespace(ns) {
191 const base = String(ns || "");
192 return {
193 ns: base,
194 ev: (name) => (name != null && String(name).includes(".") ? String(name) : base + "." + name),
195 cls: (name) => base + "-" + name,
196 };
197}
198
199// defineApp registers a mini-app. Called at module load by each app file. Fields:
200// name, title, glyph — identity (title/glyph shown in the sidebar + header)
201// places — WHERE the app can live, the one placement declaration
202// (docs/space-apps.md): any subset of ["you", "chat", "space"].
203// "you" — a PERSONAL instance: its own single-member app-thread,
204// opened from the Apps menu and rendered full-pane (drives the
205// sidebar/Apps-menu machinery, personal-command routing, and
206// defaultCommand). "chat" — addable to a group chat, one shared
207// instance for everyone there (see "group-scoped apps" below).
208// "space" — installable FOR THE WHOLE SPACE: one shared instance in
209// the hidden all-members space thread, its commands available in
210// every chat (see "space-wide apps" below). [] = host-mounted/
211// utility, never instantiable. Every shared place asks the same of
212// the app: a pure, idempotent, validating reducer, render off
213// ctx.threadId, per-user logic keyed on e.uid. (The legacy
214// generations — personal/groupCapable/spaceCapable, and scope before
215// them, #405 — are still accepted as INPUT because stored member-app
216// manifests carry them, but normalizePlaces folds them into `places`
217// and deletes them: nothing downstream reads a capability flag.)
218// namespace — the prefix for event types + CSS classes (defaults to `name`);
219// reach it as app.ns / ctx.ev / ctx.cls (see namespace() above)
220// listed: false — NOT user-instantiable; host-mounted only (the chat feed, the
221// setup/welcome conversations, the groupapps registry). Defaults to
222// `places.length > 0`. `listed:false` + places ["space"] is the
223// IMPLICITLY SPACE-PLACED shape (see instanceThread): a command-only
224// utility whose one shared copy is the space's, with no install
225// gesture because it has no Add button — /gif, whose Giphy key is
226// one value the whole space shares.
227// quiet: true — a command answering in place (ctx.reply/ctx.panel, or an event
228// it emits) must never itself force the app's PERSONAL thread into view.
229// The thread still exists and still receives every event — it's the
230// only place the interaction is durably recorded — this only affects
231// whether it's SHOWN: brand new → created but left hidden; already
232// visible → unaffected; was visible then closed → activity doesn't
233// reopen it (the user's own tap, e.g. an action-chip ctx.open(), still
234// does). See shell.js's createAppThread + the "msg" WS handler, and
235// issue #227. Irrelevant to a chat-placed app's shared instance — that
236// thread is the chat the user is already looking at, never created by
237// the command.
238// events — the fold, as { "<type>": (state, event) => state }
239// initialState — () => the empty state (seeded on the first event)
240// commands — slash commands the app owns, as
241// { "/todo": (args, ctx) => {…} } or, to feed the "/" typeahead,
242// { "/todo": { run, desc: "Add a to-do", args: "<task>" } }.
243// `local: true` scopes a command to the app's OWN thread: it resolves
244// (and shows in the typeahead) only there, never globally — the
245// in-thread verb (/add) next to the global routing verb (/todo).
246// defaultCommand — the name of one of the app's commands that plain,
247// non-slash text typed into the app's thread runs — usually a local
248// verb (todo's "/add", so typing "buy milk" just adds it), but an app
249// whose global verb IS its in-thread grammar names that instead
250// (remind's "/remind"). Only honoured while NO bot member is in the
251// thread — with an LLM present, plain text stays a real message for
252// it to answer (see defaultCommandFor).
253// render, styles (styles injected once → self-contained)
254// snapshot: false — opt out of state snapshots (for an app whose state IS the
255// log, like chat, where snapshotting would duplicate the whole thread)
256// incremental: true — the framework does NOT clear the surface before render;
257// the app reconciles its own DOM (see keyedList) so appends are cheap
258// and scroll position survives re-renders
259// ephemeral(event, ctx) — handler for relay-only events (ctx.emitEphemeral):
260// never stored, never folded — presence traffic like typing
261// The owned event types (app.types) and the reducer (app.reduce) are derived from
262// `events` — one source of truth, so a type can never drift out of sync.
263// appGlyphInto paints an app's identity mark into an avatar-ish element: its
264// vendored brand logo (static in-code SVG — web/static/logos.js) when it has
265// one, else its emoji glyph. The glyph stays the app's cross-surface fallback
266// (native palettes and plain-string contexts can't render an SVG).
267export function appGlyphInto(el, app) {
268 // These avatar elements are reused across thread switches, and paintAvatar
269 // may have left a photo behind as an inline background-image (e.g. Eleven's
270 // app icon). innerHTML/textContent below replace the children but NOT that
271 // style, so clear it first or the old photo shows through behind the mark.
272 el.style.backgroundImage = "";
273 if (app && app.logo) {
274 el.classList.add("app-logo");
275 el.innerHTML = app.logo; // static in-code SVG, never user content
276 } else {
277 el.textContent = (app && app.glyph) || "▦";
278 }
279}
280
281export function defineApp(spec) {
282 const app = { commands: {}, ...spec };
283 // The places declaration (docs/space-apps.md; the "places" model): ONE
284 // field answering one question — where can this app live? Any subset of
285 // ["you", "chat", "space"]; [] = host-mounted/utility (never instantiable).
286 // Legacy declarations still normalize into it here, then drop (see
287 // normalizePlaces) — downstream code reads `places` (+ the orthogonal
288 // `listed` and `quiet`), never a capability flag.
289 normalizePlaces(app);
290 // The app's namespace drives its event-type and CSS-class prefixes. It defaults
291 // to `name` (the historical convention), so an app that declares nothing keeps
292 // behaving exactly as before; app.ns is the resolver render/commands reach via
293 // ctx.ev / ctx.cls. The module-level namespace() gives the same thing for the
294 // `events` map keys, which are evaluated before this app object exists.
295 app.namespace = app.namespace || app.name;
296 app.ns = namespace(app.namespace);
297 // Normalise each command to { run, desc, args } so a bare handler function and
298 // the richer {run,desc,args} form both work — desc/args feed the "/" typeahead.
299 app.commands = normalizeCommands(app.commands);
300 // Wire types (events) are registered in typeOwner, so anything a member posts
301 // can reach them. hostEvents fold identically but are HOST-ONLY: never
302 // registered, so no wire payload can trigger them — for state mutations the
303 // server enforces elsewhere (chat's react/edit/delete) that a member must not
304 // be able to spoof by posting a typed message.
305 app.types = Object.keys(app.events || {});
306 app.reduce = makeReducer({ ...(app.hostEvents || {}), ...(app.events || {}) }, app.initialState);
307 apps.push(app);
308 for (const t of app.types) typeOwner.set(t, app);
309 // Local commands stay off the global registry — they resolve per-thread, via
310 // the owning app (see parseCommand), so "/add" can mean different things in
311 // different app-threads and never collides across apps. A global command
312 // NEVER overwrites an existing owner: the untrusted path (member apps)
313 // already rejects a taken name up front (commandTakenBy, sandbox.js), and
314 // this is the backstop so no future entry point can silently shadow a
315 // built-in the way the missing collision check once let a crafted blob
316 // claim "/todo". First registration wins; a clash is logged, not honoured.
317 for (const [name, c] of Object.entries(app.commands)) {
318 if (c.local) continue;
319 if (commandOwner.has(name)) {
320 console.error(`command ${name} already owned by "${(commandOwner.get(name) || {}).name}" — "${app.name}" does not override it`);
321 continue;
322 }
323 commandOwner.set(name, app);
324 }
325 injectStyles(app.name, app.styles);
326 return app;
327}
328
329// normalizePlaces: every app leaves here with a concrete `places` array and a
330// concrete `listed`, and with the LEGACY placement fields gone. The old
331// generations — the capability flags (personal / groupCapable / spaceCapable)
332// and the `scope` enum before them (#405) — are still ACCEPTED as input,
333// permanently: member-app manifests are stored, encrypted user content that
334// can never be migrated, so a deployed app declaring `groupCapable: true`
335// must keep working forever. But they are input-only — normalized into
336// `places` and then DELETED — so the rest of the codebase reads exactly one
337// placement vocabulary and a stale flag can never disagree with it.
338function normalizePlaces(app) {
339 if (!Array.isArray(app.places)) {
340 if (app.scope !== undefined) {
341 // The oldest generation: "personal" | "system" ("shared" — the ambient
342 // panel — died with #405; nothing can declare it anymore).
343 if (app.personal === undefined) app.personal = app.scope === "personal";
344 if (app.listed === undefined) app.listed = app.scope !== "system";
345 } else if (app.personal === undefined && app.groupCapable === undefined && app.spaceCapable === undefined && app.listed === undefined) {
346 app.personal = true; // declaring nothing has always meant the personal archetype
347 }
348 app.places = [
349 ...(app.personal ? ["you"] : []),
350 ...(app.groupCapable ? ["chat"] : []),
351 ...(app.spaceCapable ? ["space"] : []),
352 ];
353 }
354 if (app.listed === undefined) app.listed = app.places.length > 0;
355 delete app.personal;
356 delete app.groupCapable;
357 delete app.spaceCapable;
358 delete app.scope;
359}
360
361function normalizeCommands(cmds) {
362 const out = {};
363 for (const [name, v] of Object.entries(cmds || {})) {
364 out[name] =
365 typeof v === "function"
366 ? { run: v, desc: "", args: "", local: false }
367 : { run: v.run, desc: v.desc || "", args: v.args || "", local: !!v.local };
368 }
369 return out;
370}
371
372// makeReducer turns an events map + initialState factory into a fold function
373// (state, event) → state. It seeds the empty state on the first event, dispatches
374// each event to its handler by type, and returns state UNCHANGED for any type the
375// app doesn't handle — so an app never writes a `default` case (the usual place a
376// fold silently goes wrong). Handlers must be pure: return new state, never mutate
377// the old (two devices fold the same events and must converge) — the collection
378// helpers below make that the easy path.
379function makeReducer(events, initialState) {
380 const seed = typeof initialState === "function" ? initialState : () => initialState || {};
381 return (state, event, meta) => {
382 const s = state === undefined ? seed() : state;
383 const handler = events[event.type];
384 // The 4th arg is a minimal identity ctx: some folds derive per-user state
385 // (chat.reaction's mine flag is uid === ctx.me). Only identity — a reducer
386 // must stay pure, so no emit/schedule/getState here.
387 return handler ? handler(s, event, meta || {}, { me: currentUser, meName: currentUserName }) : s;
388 };
389}
390
391// ---- collection helpers: immutable list ops for reducers ----------------
392// Most apps fold into a list of { id, … } items. These three encode the two
393// invariants a hand-written reducer keeps getting subtly wrong: every op returns
394// a NEW array (never mutate prior state), and addItem is idempotent (an item id
395// already present is a no-op — the message window is re-folded on every thread
396// reopen, so the same add MUST NOT duplicate). Build a list reducer from these
397// and it's correct by construction; sort the result freely, it's always a copy.
398export function addItem(list, item) {
399 return list.some((i) => i.id === item.id) ? list.slice() : [...list, item];
400}
401export function updateItem(list, id, patch) {
402 return list.map((i) => (i.id === id ? { ...i, ...patch } : i));
403}
404export function removeItem(list, id) {
405 return list.filter((i) => i.id !== id);
406}
407// moveItemAfter repositions an item to immediately follow `after` ("" = the
408// list's start). Unknown id or unknown anchor = a no-op (the anchor may have
409// been removed concurrently) — the same rule the schema engine's `move` fold
410// op pins, so the hand-written and schema folds stay twins.
411export function moveItemAfter(list, id, after) {
412 const from = list.findIndex((i) => i.id === id);
413 if (from < 0) return list.slice();
414 const next = list.slice();
415 const [moved] = next.splice(from, 1);
416 let to = 0;
417 if (after) {
418 const anchor = next.findIndex((i) => i.id === after);
419 if (anchor < 0) return list.slice();
420 to = anchor + 1;
421 }
422 next.splice(to, 0, moved);
423 return next;
424}
425
426// How long a just-completed item lingers in place (struck-through, with a
427// countdown ring, still undoable) before it tucks into the "completed" section.
428export const DONE_GRACE_MS = 6000;
429
430// partitionByGrace splits items into { active, grace, archived } by a completion
431// timestamp (`doneAtOf(item)` — falsy = not done). A done item is in `grace`
432// while it's younger than graceMs (shown in place, undoable), then `archived`
433// (tucked away). This is the whole "linger then drop off" rule, kept PURE and in
434// the RENDER — the reducer only records doneAt, so the fold stays deterministic
435// (a timer never mutates state); the UI derives the buckets from the clock and
436// re-derives on a tick. Order within each bucket is the input order (callers sort).
437export function partitionByGrace(items, doneAtOf, now, graceMs = DONE_GRACE_MS) {
438 const active = [];
439 const grace = [];
440 const archived = [];
441 for (const it of items || []) {
442 const d = doneAtOf(it) || 0;
443 if (!d) active.push(it);
444 else if (now - d < graceMs) grace.push(it);
445 else archived.push(it);
446 }
447 return { active, grace, archived };
448}
449
450// ---- keyedList: incremental DOM reconciliation for list surfaces ---------
451// Renders `items` into `el`, reusing existing DOM nodes by key: unchanged items
452// keep their exact nodes (scroll position, focus, loaded images survive), new
453// items are built, removed items leave, and an item whose `rev` changed is
454// re-rendered in place. The container must be fully managed by keyedList —
455// don't append other children to it. Pair with `incremental: true` so the
456// framework doesn't wipe the container between renders.
457// keyedList(el, items, {
458// key: (item) => string — stable identity
459// rev: (item) => any — rebuild the item when this changes (optional)
460// srev: (item) => any — a "soft" rev: when it changes but `rev` doesn't,
461// update() patches the node in place (optional)
462// update: (node, item) => void — reconcile a retained node's soft parts without
463// rebuilding it, keeping loaded images/embeds (optional)
464// render: (item) => Node — build one item's DOM
465// })
466// The rev/srev split keeps expensive, stateful subtrees (a decrypted image, a
467// playing embed) alive across changes that only touch peripheral chrome: bump
468// `rev` for a content change (full rebuild), but move a peripheral signal into
469// `srev` and reconcile it in update() so the node itself is never re-rendered.
470export function keyedList(el, items, opts) {
471 const { key, render, rev = () => "", srev = () => "", update } = opts;
472 const existing = new Map();
473 for (const child of [...el.children]) {
474 if (child.dataset && child.dataset.k !== undefined) existing.set(child.dataset.k, child);
475 }
476 let cursor = null; // the last placed node; the next item belongs right after it
477 for (const item of items) {
478 const k = String(key(item));
479 const r = String(rev(item));
480 const sr = String(srev(item));
481 let node = existing.get(k);
482 existing.delete(k);
483 if (node && node.dataset.rev !== r) {
484 const fresh = render(item);
485 fresh.dataset.k = k;
486 fresh.dataset.rev = r;
487 fresh.dataset.srev = sr;
488 node.replaceWith(fresh);
489 node = fresh;
490 } else if (!node) {
491 node = render(item);
492 node.dataset.k = k;
493 node.dataset.rev = r;
494 node.dataset.srev = sr;
495 } else if (update && node.dataset.srev !== sr) {
496 // Retained node, hard rev unchanged: patch only its soft parts in place —
497 // never a rebuild, so any loaded image / live embed inside it survives.
498 update(node, item);
499 node.dataset.srev = sr;
500 }
501 const want = cursor ? cursor.nextSibling : el.firstChild;
502 if (node !== want) el.insertBefore(node, want);
503 cursor = node;
504 }
505 for (const gone of existing.values()) gone.remove();
506}
507
508// appByName / personalApps let shell.js render an app by id and list the apps a
509// user can start (the "Apps" section of the New-chat dialog).
510export function appByName(name) {
511 return apps.find((a) => a.name === name) || null;
512}
513export function personalApps() {
514 return apps.filter((a) => a.places.includes("you"));
515}
516// Every user-visible app, for the Apps sheet's directory view — including
517// personal-only ones, so "why can't I add X here?" has a discoverable answer
518// (the placeNote / inferred reason rendered on unsupported places).
519export function listedApps() {
520 return apps.filter((a) => a.listed);
521}
522
523// appIsQuiet: does this app opt out of forcing its thread into view on mere
524// activity (see `quiet` in defineApp's doc comment, and issue #227)? Pure
525// lookup by app name so both the create-time and the reopen-on-activity call
526// sites in shell.js share one answer.
527export function appIsQuiet(name) {
528 const app = name && appByName(name);
529 return !!(app && app.quiet);
530}
531
532// appCatalogReport serializes the registered-app catalogue for the NATIVE
533// shell — the app-host protocol (docs/native-apps.md). The native apps can't
534// execute app JS, so this page (which just did, across all three tiers:
535// built-in, admin custom, member) reports every app's manifest facts as plain
536// JSON: identity, places, presentation, and commands. That one report is what
537// lets native drive its registry, pickers, surface routing, and composer
538// typeahead for ANY app with zero per-app native code. Plain data only — a
539// WKWebView evaluateJavaScript call returns it directly.
540export function appCatalogReport() {
541 return apps.map((app) => ({
542 name: app.name,
543 title: app.title || app.name,
544 glyph: app.glyph || "▦",
545 blurb: app.blurb || "",
546 places: [...app.places],
547 listed: !!app.listed,
548 quiet: !!app.quiet,
549 member: !!app.memberApp,
550 // Native chrome (docs/icons.md): the SF Symbols name / vendored template
551 // asset iOS and Mac render for this app — declared HERE, in the app's own
552 // spec beside its web `logo`, so no native table has to be kept in step.
553 // "" = none: the app's emoji glyph is its face everywhere.
554 symbol: app.symbol || "",
555 mark: app.mark || "",
556 // Some apps (board) gate their visible content behind an "open" event
557 // rather than rendering the moment they're added — the add flow fires it
558 // right after the install lands (installGroupApp), and native add paths
559 // must do the same or the app sits silently present but empty.
560 openEvent: app.openEvent || "",
561 // The connected-service gate ("" = ungated): DNSimple's app only
562 // "installs" once you've linked DNSimple — the pickers hide gated apps
563 // until /api/connected lists the service (newchat.js matchingApps).
564 requiresService: app.requiresService || "",
565 // Whether the app HAS an openable surface (a render) — command-only apps
566 // (gif) get no sidebar/space-band row anywhere, native included.
567 surface: typeof app.render === "function",
568 // The presentation contract, pre-answered (same rules as appWantsChatView
569 // / appChatOnly / appDefaultView) so native never re-derives it: companion
570 // = has a Chat view; chatOnly = ONLY a Chat view; otherwise no companion
571 // means the app's render takes over the whole surface.
572 companion: typeof app.chatMessage === "function",
573 chatOnly: !!(typeof app.chatMessage === "function" && app.chatOnly),
574 chatReadOnly: !!app.chatReadOnly,
575 defaultView: app.defaultView === "chat" || app.defaultView === "interface" ? app.defaultView : "split",
576 defaultCommand: app.defaultCommand && app.commands[app.defaultCommand] ? app.defaultCommand : "",
577 commands: Object.entries(app.commands).map(([name, c]) => ({
578 name,
579 desc: c.desc || "",
580 args: c.args || "",
581 local: !!c.local,
582 })),
583 }));
584}
585
586// appWantsChatView decides how a personal app-thread presents. An app that
587// implements chatMessage(event) — the hook projecting its events into chat
588// bubbles / action lines — opts into the COMPANION view: a Chat conversation of
589// the thread alongside its Interface, toggled in the header (the To-do / Reminders
590// experience). An app WITHOUT chatMessage TAKES OVER the thread's full surface:
591// its render() is the whole thread, no chat view and no toggle (the original
592// full-pane model). "Wants a chat view" and "knows how to project into chat" are
593// the same fact, so companion stays opt-in and never mandatory — see CLAUDE.md.
594//
595// 🤖 That rule holds with NO exceptions because feed projection is its own
596// hook: an app that wants its events narrated in a GROUP chat's feed without
597// ever growing a Chat view of its own private thread (the Vault) implements
598// groupFeedMessage(event) INSTEAD of chatMessage — see foldGroupAppEventBubble
599// (shell.js), which prefers it and falls back to chatMessage(event,
600// { group: true }) so a companion app projects into groups with one hook.
601// (This replaced the old `groupChatOnly` flag, whose name pointed at the
602// group when its one effect was here, on the you-place presentation.)
603export function appWantsChatView(name) {
604 const app = appByName(name);
605 return !!(app && typeof app.chatMessage === "function");
606}
607
608// appDefaultView is the view a companion app-thread opens in when the user
609// hasn't picked one this session: "split" unless the app says otherwise. An
610// interface-first companion (the Admin app — a settings panel whose chat is a
611// read-only notice stream) declares `defaultView: "interface"` so it opens as
612// the app, with the conversation one toggle away.
613export function appDefaultView(name) {
614 const app = appByName(name);
615 const v = app && app.defaultView;
616 return v === "chat" || v === "interface" ? v : "split";
617}
618
619// appChatReadOnly: the owning app declares its Chat view is a read-only stream
620// (`chatReadOnly: true`) — the app posts into it, the user doesn't. The chat
621// app hides its composer for these threads (like the native embed, where the
622// shell owns the composer). Keyed by thread so chat.js can ask about the one
623// it's rendering.
624export function appChatReadOnly(threadId) {
625 const app = threadOwnerApp(threadId);
626 return !!(app && app.chatReadOnly);
627}
628
629// appChatOnly is the third presentation: a companion that is ONLY its chat view
630// (chatMessage projections, no Interface, no view toggle). For app-threads that
631// are purely a conversation — the setup thread, whose old progress card the
632// Admin panel superseded.
633export function appChatOnly(name) {
634 const app = appByName(name);
635 return !!(app && typeof app.chatMessage === "function" && app.chatOnly);
636}
637
638// ---- group-scoped apps ----------------------------------------------------
639//
640// A `groupCapable: true` personal app (To-do) can ALSO be added to a group
641// chat, where ONE shared instance runs for everyone: its events post into the
642// GROUP thread's encrypted log, so every member folds the same state — the same
643// events, the same reducer, a different thread. Nothing else changes: the fold,
644// snapshots, scheduling, and live delivery are already per-thread.
645//
646// Which apps a group has added is itself fold state, from wire events in the
647// group's own log — so it syncs to every member, survives via snapshots, and
648// the server never learns which app was added (or that one was). Any member can
649// add or remove one, like adding people; both acts show as action lines.
650// ONE registry app for every shared place (phase 3 of the places model): which
651// apps a container has is fold state in THAT container's log — a group
652// thread's for "this chat", the space thread's for "everyone" — so the server
653// never learns which apps live where. The event families stay the legacy wire
654// types (`groupapps.*` in group logs, `spaceapps.*` in the space log) FOREVER:
655// logs are immutable and old clients still fold them, so the unification is in
656// the fold, not the wire. This is the one sanctioned exception to "prefix
657// event types with your app name" — the registry owns both families as
658// aliases. Item shapes stay exactly what the per-family vectors pin
659// (test/event-vectors/{groupapps,spaceapps}.json): group items {id, by, ts},
660// space items {id, by, uid, ts} — uid feeds the admins-only install policy.
661export const installsApp = defineApp({
662 name: "installs",
663 title: "Apps",
664 glyph: "▦",
665 places: [], // a registry, not a surface — lives nowhere, never listed, never given a panel
666 initialState: () => ({ apps: [] }),
667 events: {
668 // The item id IS the app name, so re-adding is idempotent (addItem dedupes)
669 // and a remove needs no lookup. Unknown app names fold fine — they just
670 // don't surface until a client that knows the app reads the state (see
671 // groupAppsFor / spaceAppsFor), so old and new clients converge.
672 "groupapps.add": (s, e) =>
673 typeof e.app === "string" && e.app ? { apps: addItem(s.apps, { id: e.app, by: e.name || "", ts: e.ts || 0 }) } : s,
674 "groupapps.remove": (s, e) => ({ apps: removeItem(s.apps, e.app) }),
675 "spaceapps.add": (s, e) =>
676 typeof e.app === "string" && e.app
677 ? { apps: addItem(s.apps, { id: e.app, by: e.name || "", uid: e.uid || "", ts: e.ts || 0 }) }
678 : s,
679 "spaceapps.remove": (s, e) => ({ apps: removeItem(s.apps, e.app) }),
680 },
681 // Adding or removing an app is a visible act in the CONVERSATION only for a
682 // chat's own copies — the group fold prefixes the actor ("Ciara added To-do
683 // to this chat"). Space events project nothing: the space thread has no chat
684 // view; the Apps sheet's "Added for everyone by …" line is their record.
685 // groupFeedMessage, not chatMessage: the registry has no thread of its own,
686 // so this hook exists ONLY to narrate group feeds — and chatMessage's
687 // meaning is "grow a Chat view" (appWantsChatView).
688 groupFeedMessage(e) {
689 const title = (appByName(e.app) || {}).title || e.app || "an app";
690 if (e.type === "groupapps.add") return { text: `added ${title} to this chat`, action: true };
691 if (e.type === "groupapps.remove") return { text: `removed ${title} from this chat`, action: true };
692 return null;
693 },
694 // The sidebar line for a chat where an app was just added/removed.
695 preview(e) {
696 const title = (appByName(e.app) || {}).title || e.app || "an app";
697 if (e.type === "groupapps.add") return `${e.name || "Someone"} added ${title}`;
698 if (e.type === "groupapps.remove") return `${e.name || "Someone"} removed ${title}`;
699 return null;
700 },
701});
702// Back-compat names (tests, older call sites): both registries ARE the one app.
703export const groupAppsApp = installsApp;
704
705// ---- space-wide apps -------------------------------------------------------
706//
707// A `spaceCapable: true` app can be installed FOR THE WHOLE SPACE: one shared
708// instance whose events and state live in the hidden, all-members SPACE THREAD
709// (docs/space-apps.md), so the app — its commands, its data — is available in
710// every chat without anyone adding it anywhere. This is the groupapps model one
711// level up: which apps the space has installed is itself fold state, from wire
712// events in the space thread's log, so it syncs to every member, survives via
713// snapshots, and the server never learns which app was installed (or that one
714// was). Any member can install or remove one, attributed by name.
715export const spaceAppsApp = installsApp;
716
717// The space thread backing space-wide installs. shell.js injects a SYNC getter
718// (the thread's id, or null until it's been fetched/created and its key
719// unwrapped) plus an ASYNC find-or-create resolver used by installs — creation
720// needs the instance key and a server round-trip, which live in the host.
721let spaceThreadFn = null;
722export function setSpaceThread(fn) {
723 spaceThreadFn = fn;
724}
725export function spaceThreadId() {
726 return (spaceThreadFn && spaceThreadFn()) || null;
727}
728let spaceThreadResolver = null; // () → Promise<threadId | null>
729export function setSpaceThreadResolver(fn) {
730 spaceThreadResolver = fn;
731}
732
733// The space-install policy (docs/space-apps.md § controls): a plaintext admin
734// setting — "who may add apps for everyone: everyone (default) / admins".
735// shell.js injects { adminsOnly, canInstall, adderOK(uid) }. Enforced twice,
736// both client-side (the registry is E2EE — the server can't referee): the Add
737// affordances hide/refuse for non-admins, and spaceAppsFor filters at READ
738// time, treating installs by non-admin adders as inert while the policy says
739// admins-only. Read-time (not fold-time) keeps folds deterministic and makes
740// the policy reversible — flip it back and those installs reappear. Items
741// folded before uids were recorded are grandfathered.
742let spaceInstallPolicyFn = null;
743export function setSpaceInstallPolicy(fn) {
744 spaceInstallPolicyFn = fn;
745}
746function spaceInstallPolicy() {
747 return (spaceInstallPolicyFn && spaceInstallPolicyFn()) || { adminsOnly: false, canInstall: true, adderOK: () => true };
748}
749
750// The apps a member could install for the whole space — feeds the Apps sheet.
751export function spaceCapableApps() {
752 return apps.filter((a) => a.places.includes("space"));
753}
754
755// The apps INSTALLED for the space: the space thread's folded spaceapps state,
756// resolved against the registry and filtered to actually-space-capable apps —
757// a hostile or stale add naming anything else stays inert (groupAppsFor's rule).
758export function spaceAppsFor() {
759 const tid = spaceThreadId();
760 if (!tid) return [];
761 const s = appStateFor(tid, "installs");
762 if (!s || !Array.isArray(s.apps)) return [];
763 const policy = spaceInstallPolicy();
764 const out = [];
765 for (const it of s.apps) {
766 const a = it && appByName(it.id);
767 if (!a || !a.places.includes("space")) continue;
768 // Admins-only policy: a non-admin's install is inert while it holds.
769 if (policy.adminsOnly && it.uid && !policy.adderOK(it.uid)) continue;
770 out.push(a);
771 }
772 return out;
773}
774
775// Who installed a space app, for the sheet's attribution line ({by, ts} or
776// null). Same read-time filter as spaceAppsFor: an inert registry entry (an
777// unknown or non-space-capable name) is not an install, so it has no installer.
778export function spaceAppInfo(name) {
779 const a = appByName(name);
780 if (!a || !a.places.includes("space")) return null;
781 const tid = spaceThreadId();
782 const s = tid && appStateFor(tid, "installs");
783 const it = s && Array.isArray(s.apps) && s.apps.find((x) => x && x.id === name);
784 if (!it) return null;
785 // The same read-time policy filter spaceAppsFor applies: while installs
786 // are admins-only, a non-admin's add is inert for routing — attribution
787 // must not report "Installed by …" for an install every other surface
788 // suppresses.
789 const policy = spaceInstallPolicy();
790 if (policy.adminsOnly && it.uid && !policy.adderOK(it.uid)) return null;
791 return { by: it.by || "", ts: it.ts || 0 };
792}
793
794// instanceThread resolves THE routing sentence — "a command acts on the
795// nearest copy of its app": the app's own thread, then this chat's instance,
796// then the space's, then your personal thread (created on first use). One
797// truth for commands and mentions; the widget bar mirrors the same order
798// synchronously (shell.js renderGroupAppPane). Returns null when no copy is
799// reachable (e.g. keys still syncing).
800export async function instanceThread(app, activeThreadId, opts = {}) {
801 if (opts.local || threadOwnerApp(activeThreadId) === app) return activeThreadId;
802 if (groupAppsFor(activeThreadId).some((a) => a.name === app.name)) return activeThreadId;
803 // An UNLISTED space app is IMPLICITLY space-placed. `listed:false` means it is
804 // not user-instantiable — it has no Add button — so there is no install event
805 // to gate on, and its one shared copy simply IS the space's, mirroring the
806 // space thread's own implicit membership. Resolve (minting the thread on first
807 // use) rather than falling through to `return activeThreadId` below, which
808 // would land the app's data in whatever conversation the member happened to be
809 // typing in — readable by that chat's members and in the wrong log forever.
810 // Such an app addresses the chat through the host, not ctx: ctx.panel already
811 // opens where you typed (see runCommand), and posting content there is a
812 // host doorway's job (gif's `post`) — "app data lives space-wide, conversation
813 // content stays under the conversation's own key" (docs/space-apps.md).
814 if (app.places.includes("space") && !app.listed) return spaceThreadId() || (spaceThreadResolver ? await spaceThreadResolver() : null);
815 if (app.places.includes("space") && spaceAppsFor().some((a) => a.name === app.name)) return spaceThreadId();
816 if (app.places.includes("you")) return resolveAppThread ? await resolveAppThread(app.name) : null;
817 return activeThreadId;
818}
819
820// installSpaceApp emits the spaceapps.add for `app`, creating the space thread
821// on first use (the resolver mints its key wrapped under the instance key).
822// Returns true when the install event was sent (or the app was already
823// installed) — shared by the Apps sheet and the "@everyone" command flag.
824export async function installSpaceApp(app) {
825 if (!spaceInstallPolicy().canInstall) return false;
826 if (spaceAppsFor().some((a) => a.name === app.name)) return true;
827 const tid = spaceThreadResolver ? await spaceThreadResolver() : null;
828 if (!tid) return false;
829 return emitAppEvent(tid, { type: "spaceapps.add", app: app.name });
830}
831
832// installGroupApp emits the groupapps.add for `app` in `threadId`. Returns
833// true when the install event was sent (or the app was already added) —
834// shared by both Apps sheets (legacy + places UI) so every doorway that adds
835// a shared app to a chat behaves the same. Some apps (board) gate their own
836// visible content behind a separate "open" event rather than rendering
837// something the moment they're added (see board.js's `openEvent` doc
838// comment) — for those, fire that event too, right after the add lands, so
839// the app is actually visible instead of silently present but empty.
840export async function installGroupApp(threadId, app) {
841 if (groupAppsFor(threadId).some((a) => a.name === app.name)) return true;
842 if (!(await emitAppEvent(threadId, { type: "groupapps.add", app: app.name }))) return false;
843 if (app.openEvent) await emitAppEvent(threadId, { type: app.openEvent });
844 return true;
845}
846
847// The apps a user could add to a group — feeds the "Add apps" picker.
848export function groupCapableApps() {
849 return apps.filter((a) => a.places.includes("chat") && a.render);
850}
851
852// The apps ADDED to a group thread: the thread's folded groupapps state,
853// resolved against the registry and filtered to apps that are actually
854// group-capable — a hostile or stale add naming anything else stays inert.
855export function groupAppsFor(threadId) {
856 const s = appStateFor(threadId, "installs");
857 if (!s || !Array.isArray(s.apps)) return [];
858 const out = [];
859 for (const it of s.apps) {
860 const a = it && appByName(it.id);
861 if (a && a.places.includes("chat") && a.render) out.push(a);
862 }
863 return out;
864}
865
866// isAppType reports whether some app owns this payload type — i.e. this message
867// is an app event to fold, not a chat bubble to render.
868export function isAppType(type) {
869 return type != null && typeOwner.has(type);
870}
871
872// appForType returns the app owning a wire event type, or null — for a host
873// that already holds a decrypted event and needs its app (e.g. to project it
874// into a group conversation) without re-deriving ownership itself.
875export function appForType(type) {
876 return (type != null && typeOwner.get(type)) || null;
877}
878
879// ---- per-thread state: threadId → (appName → reducerState) --------------
880
881const store = new Map();
882const foldedIds = new Map(); // threadId → Map(appName → Set(server message id) folded)
883const seededUpTo = new Map(); // threadId → snapshot horizon (see seedThread)
884const maxFolded = new Map(); // threadId → highest server message id folded
885const dirty = new Set(); // threads with folded state newer than the last snapshot
886
887function threadStore(threadId) {
888 let m = store.get(threadId);
889 if (!m) {
890 m = new Map();
891 store.set(threadId, m);
892 }
893 return m;
894}
895
896// ---- render teardown: ctx.interval / ctx.onCleanup ----------------------
897// A render can start things that outlive the DOM it built — chiefly a ticker
898// (setInterval). Rather than have each app stash a timer on a node and clear it
899// by hand, the framework tracks per-(thread,app) cleanups: they run before the
900// surface is redrawn and when the thread is reset. Keyed so two apps or two
901// threads never clear each other's.
902const cleanups = new Map(); // "threadId appName" → fn[]
903function cleanupKey(threadId, appName) {
904 return threadId + " " + appName;
905}
906function addCleanup(threadId, appName, fn) {
907 const k = cleanupKey(threadId, appName);
908 let arr = cleanups.get(k);
909 if (!arr) cleanups.set(k, (arr = []));
910 arr.push(fn);
911}
912function runCleanups(threadId, appName) {
913 const fns = cleanups.get(cleanupKey(threadId, appName));
914 if (!fns) return;
915 cleanups.delete(cleanupKey(threadId, appName));
916 for (const fn of fns) {
917 try {
918 fn();
919 } catch {}
920 }
921}
922
923// resetThread drops a thread's folded state (and the folded-id set) so its window
924// can be re-folded from scratch — called before a fresh feed build (openThread).
925// It also runs any pending render cleanups for the thread (e.g. stops tickers).
926export function resetThread(threadId) {
927 store.delete(threadId);
928 foldedIds.delete(threadId);
929 seededUpTo.delete(threadId);
930 maxFolded.delete(threadId);
931 dirty.delete(threadId);
932 for (const app of apps) runCleanups(threadId, app.name);
933}
934
935// ---- state snapshots: fold state that outlives the message window --------
936// The fold's source of truth is the encrypted event log, but the client only
937// holds a window of it (the last ~100 messages). A snapshot is the folded state
938// as of message id `upTo`, saved encrypted to the server (thread_state) and
939// loaded on open: seed the snapshot, then fold only events NEWER than upTo.
940// Because folds are pure and deterministic, snapshot + newer events ≡ folding
941// the whole history — nothing is lost when old events fall out of the window.
942
943// seedThread installs a loaded snapshot: per-app states plus the horizon it
944// covers. Events with meta.id <= upTo are already inside it, so foldEvent skips
945// re-reducing them.
946//
947// The snapshot at `upTo` is the AUTHORITATIVE floor for a thread's state, so we
948// install it even when the thread already carries folded state. The old code
949// skipped a re-seed ("a thread that already has state keeps it"), which lost data
950// in a race: openThread does resetThread(id) then `await seedAppState(id)`, and a
951// live app event arriving during the snapshot-load await folds into store[id]
952// first. Returning early then dropped every item the interim events don't re-cover,
953// and — because maxFolded had advanced to the interim id — persisted that truncated
954// state at an advanced horizon, after which foldInto's `mid <= upTo` skip drops the
955// pre-horizon history forever (it never self-heals).
956//
957// So reinstall the snapshot as the base and clear the forward-fold bookkeeping:
958// below-horizon interim folds are superseded by the snapshot, and ABOVE-horizon
959// interim events are restored by the caller's catch-up (seedAppState re-reads
960// everything after upTo immediately after this). That restore is load-bearing —
961// seedThread's only caller runs that catch-up; a future caller that seeds WITHOUT
962// one would silently drop interim events. Clearing foldedIds/maxFolded is safe
963// because dedupe is an optimization, not the correctness boundary: every reducer is
964// idempotent by entity id (Invariant #1), so re-folding the interim + window events
965// converges. Clearing maxFolded + dirty also closes the second half of the bug — a
966// debounced save firing between seed and catch-up can no longer anchor base-only
967// state at the interim horizon.
968export function seedThread(threadId, states, upTo) {
969 const m = new Map();
970 for (const [app, state] of Object.entries(states || {})) m.set(app, state);
971 // Registry unification shim: snapshots written before the one `installs`
972 // registry hold its state under the legacy per-family keys ("groupapps" in
973 // group threads, "spaceapps" in the space thread — a thread only ever has
974 // one). Seed the unified key from them so the horizon skip stays sound; the
975 // next snapshot save writes "installs" and the legacy key ages out.
976 if (!m.has("installs")) {
977 const legacy = m.get("groupapps") || m.get("spaceapps");
978 if (legacy) m.set("installs", legacy);
979 }
980 store.set(threadId, m);
981 seededUpTo.set(threadId, upTo || 0);
982 foldedIds.delete(threadId);
983 maxFolded.delete(threadId);
984 dirty.delete(threadId);
985}
986
987// takeSnapshot returns { states, upTo } when the thread has folded anything new
988// since the last snapshot/seed, else null. Taking it marks the thread clean; the
989// caller is expected to save it (a failed save just means the next fold re-dirties).
990export function takeSnapshot(threadId) {
991 if (!dirty.has(threadId)) return null;
992 const upTo = Math.max(maxFolded.get(threadId) || 0, seededUpTo.get(threadId) || 0);
993 if (!upTo) return null; // only events with real server ids can anchor a snapshot
994 dirty.delete(threadId);
995 const states = {};
996 for (const [name, state] of threadStore(threadId)) {
997 const a = appByName(name);
998 if (a && a.snapshot === false) continue; // log-backed apps (chat) never snapshot
999 states[name] = state;
1000 }
1001 return { states, upTo };
1002}
1003
1004// After a fold changes state, tell the host (shell.js) so it can persist a fresh
1005// snapshot. Debounced per thread: bursts of events (catch-up) save once.
1006let stateSaver = null; // (threadId) => void — host loads takeSnapshot itself
1007export function setStateSaver(fn) {
1008 stateSaver = fn;
1009}
1010const saveTimers = new Map();
1011function scheduleSnapshotSave(threadId) {
1012 if (!stateSaver || typeof setTimeout === "undefined") return;
1013 clearTimeout(saveTimers.get(threadId));
1014 saveTimers.set(
1015 threadId,
1016 setTimeout(() => {
1017 saveTimers.delete(threadId);
1018 stateSaver(threadId);
1019 }, 3000),
1020 );
1021}
1022
1023export function appStateFor(threadId, appName) {
1024 return threadStore(threadId).get(appName);
1025}
1026
1027// installMemberAppState swaps a sandboxed member app's frame-reported fold in
1028// as the proxy's value (sandbox.js; docs/member-apps.md). The proxy's store
1029// state is { v, vUpTo, pend }: `v` is the app's real folded state as reported
1030// by its sandbox frame through message id `vUpTo`, `pend` the events queued
1031// since. The swap is horizon-neutral — v+trimmed-pend represents exactly the
1032// same fold as before, just pre-reduced — so it deliberately does NOT touch
1033// maxFolded/foldedIds; it only re-dirties the thread so the snapshot debounce
1034// persists the compacted form. Gated on the memberApp marker: this doorway
1035// exists for sandbox proxies alone.
1036export function installMemberAppState(threadId, appName, value, upTo, chat) {
1037 const app = appByName(appName);
1038 if (!app || !app.memberApp) return;
1039 const states = threadStore(threadId);
1040 const prev = states.get(appName) || {};
1041 // Monotonic horizon only: a second frame (e.g. a transient command frame that
1042 // briefly coexists with the thread's visible one) can report an OLDER horizon
1043 // than the store already holds; applying it would regress `v` to an earlier
1044 // fold while the trim below drops the intervening pend. Ignore stale reports —
1045 // the live frame's next report (and reseed on reopen) is authoritative.
1046 if ((prev.vUpTo || 0) > (upTo || 0)) return;
1047 const pend = (prev.pend || []).filter((p) => p && p.meta && p.meta.id > upTo);
1048 // Preserve `lost`: it records that a MAX_PEND overflow permanently dropped
1049 // events (sandbox.js queueEvent), and the frame — which doesn't know about
1050 // it — must not wipe it on its next state report, or the "missing items"
1051 // warning vanishes and the lossy snapshot propagates silently.
1052 // `chat` (companion projections) is snapshotted alongside so the Chat view
1053 // survives the message window; keep the previous value when not reported.
1054 states.set(appName, {
1055 v: value,
1056 vUpTo: upTo || 0,
1057 pend,
1058 chat: chat !== undefined ? chat : prev.chat,
1059 lost: prev.lost,
1060 });
1061 dirty.add(threadId);
1062 scheduleSnapshotSave(threadId);
1063}
1064
1065// memberCtx builds a host ctx bound to a thread for a sandbox proxy — used by
1066// sandbox.js for an off-thread command's transient logic frame, where no
1067// render() supplied one. Same emit/schedule/etc. transports every app ctx has.
1068export function memberCtx(threadId, app) {
1069 return makeCtx(threadId, app);
1070}
1071
1072// foldEvent routes a decrypted app-event payload into its owning app's reducer and
1073// stores the new state. Returns the owning app (so the caller can refresh its
1074// surface / render an inline line), or null if no app owns the type OR this exact
1075// message was already folded. Dedup is keyed on the unique server message id
1076// (meta.id): the message window gets re-read on every thread re-open (catch-up),
1077// so folding MUST be idempotent — otherwise each reopen re-adds every item.
1078// An event at or below the thread's snapshot horizon is already inside the seeded
1079// state — it's recognised (the app is returned, so e.g. its inline line renders)
1080// but not re-reduced. That skip only applies to snapshotting apps: a snapshot
1081// never contains a snapshot:false app's state, so its events below another
1082// app's horizon must still fold.
1083export function foldEvent(threadId, event, meta) {
1084 const app = typeOwner.get(event.type);
1085 if (!app || !app.reduce) return null;
1086 return foldInto(threadId, app, event, meta);
1087}
1088
1089// optimisticFold shows a just-emitted event immediately — a LOCAL echo folded
1090// before the server round-trips it back — so a send lands in the UI with no
1091// network wait (and, for a drawing, no flash of the line vanishing until the
1092// echo). It carries no server message id (meta.id): the fold-level de-dupe is
1093// skipped, and the AUTHORITATIVE echo (which does carry one) re-folds the same
1094// event to become the real record. That's only safe when the reducer is
1095// idempotent by the event's OWN id — the same property every reducer already
1096// needs, since the message window re-folds on every thread re-open — so an app
1097// opts in explicitly with `optimistic: true` rather than us assuming it. `_cid`
1098// (a client id shell.js tucks into the payload) rides back in the echo verbatim,
1099// so a surface can pair the pending copy with its confirmation. Chat is host-fed
1100// (chat.msg) and handled by shell.js directly.
1101export function optimisticFold(threadId, event, cid) {
1102 const app = typeOwner.get(event.type);
1103 if (!app || !app.reduce || !app.optimistic) return null;
1104 return foldInto(threadId, app, event, { cid, ts: event.ts, pending: true });
1105}
1106
1107// foldHostEvent folds a HOST-SYNTHESIZED event into a named app, reaching its
1108// hostEvents handlers — the doorway shell.js uses to feed chat messages and
1109// server-enforced mutations (reactions, edits, deletes) into the chat app.
1110// Deliberately not reachable from wire payloads: foldEvent routes only by owned
1111// wire type, so a member can't spoof these by posting a typed message.
1112export function foldHostEvent(threadId, appName, event, meta) {
1113 const app = appByName(appName);
1114 if (!app || !app.reduce) return null;
1115 return foldInto(threadId, app, event, meta);
1116}
1117
1118function foldInto(threadId, app, event, meta) {
1119 const mid = meta && meta.id;
1120 if (mid != null) {
1121 // De-dupe is PER APP, not per thread: one server message can legitimately fold
1122 // into two apps at once — e.g. an app-thread event folds into its owning app
1123 // (state) AND into the chat app (an action-line bubble). A per-thread set would
1124 // let the first fold "win" and silently drop the second.
1125 let byApp = foldedIds.get(threadId);
1126 if (!byApp) foldedIds.set(threadId, (byApp = new Map()));
1127 let seen = byApp.get(app.name);
1128 if (!seen) byApp.set(app.name, (seen = new Set()));
1129 if (seen.has(mid)) return null; // already folded this message into THIS app — skip (no dup)
1130 seen.add(mid);
1131 if (app.snapshot !== false && mid <= (seededUpTo.get(threadId) || 0)) return app; // inside the snapshot
1132 }
1133 const states = threadStore(threadId);
1134 const prev = states.has(app.name) ? states.get(app.name) : undefined;
1135 states.set(app.name, app.reduce(prev, event, meta || {}));
1136 // Post-fold hook (optional per app): lets a host-level script react to a
1137 // record landing from ANOTHER client — e.g. the setup flow re-deriving its
1138 // parked question when a second device answers it (apps/setup.js `folded`).
1139 // After the reduce, so state already reflects the event; a hook must never
1140 // break the fold.
1141 if (app.folded) {
1142 try { app.folded(threadId, event, meta || {}); } catch { /* hook-only failure */ }
1143 }
1144 if (mid != null && app.snapshot !== false) {
1145 maxFolded.set(threadId, Math.max(maxFolded.get(threadId) || 0, mid));
1146 dirty.add(threadId);
1147 scheduleSnapshotSave(threadId);
1148 }
1149 return app;
1150}
1151
1152// ---- emit transport: how an app event reaches the wire ------------------
1153// shell.js owns the keys/socket, so it injects the transport rather than apps.js
1154// reaching into crypto/ws. transport(threadId, event) → Promise<boolean>.
1155
1156let transport = null;
1157export function setEmitTransport(fn) {
1158 transport = fn;
1159}
1160
1161// emitAppEvent lets a HOST-LEVEL script (the setup conversation) emit a typed
1162// event into a thread outside any app ctx — same transport, same encryption,
1163// same injected name/ts/uid. Apps themselves should keep using ctx.emit.
1164export function emitAppEvent(threadId, event) {
1165 return transport ? transport(threadId, event) : Promise.resolve(false);
1166}
1167
1168// Scheduling: the server holds an encrypted "future message" and delivers it
1169// into the thread at the due time — it arrives exactly like any other event
1170// (folded, notified, pushed to closed apps), the server knowing only (thread,
1171// time). shell.js injects { schedule(threadId, event, whenMs) → Promise<sid>,
1172// cancel(sid) → Promise<boolean> } since it owns the keys and the token.
1173let schedTransport = null;
1174export function setScheduleTransport(t) {
1175 schedTransport = t;
1176}
1177
1178// Ephemeral transport: relay-only delivery for presence traffic (typing, live
1179// cursors). The server fans the encrypted blob out to the thread's members and
1180// forgets it — never stored, never pushed, never folded. Receivers dispatch it
1181// to the owning app's `ephemeral(event, ctx)` hook via deliverEphemeral.
1182let ephemeralTransport = null;
1183export function setEphemeralTransport(fn) {
1184 ephemeralTransport = fn;
1185}
1186
1187// deliverEphemeral hands a live relay-only event to its owning app's hook.
1188// Called by shell.js when an `ephemeral` frame arrives. Note: the sender gets its
1189// own echo too — a hook that shouldn't react to itself checks e.uid !== ctx.me.
1190export function deliverEphemeral(threadId, event) {
1191 const app = typeOwner.get(event && event.type);
1192 if (!app || !app.ephemeral) return;
1193 try {
1194 app.ephemeral(event, makeCtx(threadId, app));
1195 } catch {}
1196}
1197
1198// shell.js injects: the current user's id (for per-user logic like "your vote"), a
1199// resolver that finds-or-creates the user's app-thread for a personal app, and a
1200// toast fn for the "filed into your list" confirmation.
1201let currentUser = null;
1202let currentUserName = null; // your screen name — the fallback for uid-less legacy messages
1203export function setCurrentUser(uid, name) {
1204 currentUser = uid;
1205 currentUserName = name || null;
1206}
1207// 🤖 currentUserId lets a `preview(event)` hook say "You" for the reader's own
1208// event — preview hooks take no ctx (they're pure `event => string`), so this
1209// is the one narrow door to "who's reading" outside a full ctx (see vault.js).
1210export function currentUserId() {
1211 return currentUser;
1212}
1213let resolveAppThread = null; // (appName) → Promise<threadId>
1214export function setAppThreadResolver(fn) {
1215 resolveAppThread = fn;
1216}
1217let toastFn = null;
1218export function setToast(fn) {
1219 toastFn = fn;
1220}
1221// The default bot an unknown-command reply is attributed to (Eleven). shell.js
1222// resolves it from the roster and returns { name, kind } (kind "ai"/"bot" → the
1223// badge) or null when there's no bot to name.
1224let defaultBotFn = null;
1225export function setDefaultBot(fn) {
1226 defaultBotFn = fn;
1227}
1228// Whether a thread has a bot member — so /help lists the bot's commands (/theme,
1229// …) only where a bot can actually answer them, exactly like the "/" typeahead.
1230// shell.js injects its threadHasBot. Absent (or no injection) → assume none.
1231let threadHasBotFn = null;
1232export function setThreadHasBot(fn) {
1233 threadHasBotFn = fn;
1234}
1235// Whether a thread is a GROUP chat — the surface that can host shared app
1236// instances ("@here", the Add-apps sheet). shell.js injects the kind lookup;
1237// absent (Node tests inject a stub, native embeds may not) → nothing is a group,
1238// so "@here" degrades to an explanatory reply, never a mis-filed event.
1239let threadIsGroupFn = null;
1240export function setThreadIsGroup(fn) {
1241 threadIsGroupFn = fn;
1242}
1243// The memory scratchpad's store (web/static/apps/memory.js): shell.js wires
1244// load/save closures that hold the thread keys and the session token, so the
1245// app file itself never touches crypto. load(threadId) → { memory, version };
1246// save(threadId, memory, version) → new version (throws on a 409 conflict).
1247let memoryStoreFns = null;
1248export function setMemoryStore(fns) {
1249 memoryStoreFns = fns;
1250}
1251export function getMemoryStore() {
1252 return memoryStoreFns;
1253}
1254// Which app OWNS a thread (a kind=="self" app-thread's app name, else null) —
1255// shell.js injects its threadApp lookup. This is what scopes local commands and
1256// the default command to the app's own thread. Absent → no thread is app-owned.
1257let threadOwnerFn = null;
1258export function setThreadOwner(fn) {
1259 threadOwnerFn = fn;
1260}
1261// The app object owning `threadId`, or null (not an app-thread / no injection).
1262function threadOwnerApp(threadId) {
1263 const name = threadOwnerFn ? threadOwnerFn(threadId) : null;
1264 return name ? appByName(name) : null;
1265}
1266// Open a thread — used ONLY by an action chip a command offers, never by the
1267// command itself (a command answers in place; leaving the context is the user's
1268// tap — see CLAUDE.md). shell.js injects openThread.
1269let openThreadFn = null;
1270export function setThreadOpener(fn) {
1271 openThreadFn = fn;
1272}
1273// Show an app INSTANCE — ctx.open's implementation, injected by shell.js.
1274// (appName, threadId) → reveal that copy where it legitimately lives: a
1275// personal app-thread opens as a thread, a chat copy's Interface pane opens
1276// beside the chat, a space copy opens its sidebar home. Never a raw thread
1277// navigation — the space thread has no chat surface to land on.
1278let instanceOpenFn = null;
1279export function setInstanceOpener(fn) {
1280 instanceOpenFn = fn;
1281}
1282// Privileged host capabilities an app can't perform itself because they need the
1283// session keypair, roster public keys, or fetch — all of which live in shell.js.
1284// Deliberately narrow. Currently just connected third-party services
1285// (docs/connected-services.md); shell.js injects the implementation.
1286let hostBridge = null;
1287export function setHostBridge(b) {
1288 hostBridge = b;
1289}
1290// Space info for Eleven's guided tour: a promise of { name, adminName, adminEmail }
1291// — the group name plus the optional administrator identity (a member-gated read).
1292// shell.js injects the fetch; absent or failing → the tour degrades to just a name.
1293let spaceInfoFn = null;
1294export function setSpaceInfo(fn) {
1295 spaceInfoFn = fn;
1296}
1297export function spaceInfo() {
1298 if (!spaceInfoFn) return Promise.resolve({});
1299 return Promise.resolve()
1300 .then(() => spaceInfoFn())
1301 .catch(() => ({}));
1302}
1303// The default bot (Eleven), for modules that speak in Eleven's voice or need to
1304// know whether conversational AI is on here (kind === "ai"). Null when no bot.
1305export function defaultBot() {
1306 return defaultBotFn ? defaultBotFn() : null;
1307}
1308// Open a personal app's own thread (created on first use) — for a chip that invites
1309// someone to try To-do / Reminders. Leaving the current context is always their tap.
1310export async function openAppThread(name) {
1311 if (!resolveAppThread || !openThreadFn) return;
1312 const id = await resolveAppThread(name);
1313 if (id) openThreadFn(id);
1314}
1315// The guided tour lives in your DM with Eleven. When "Show me around" is tapped
1316// from anywhere else, this navigates there (finding or creating the DM) and
1317// returns a notice sink bound to it, so the rest of the tour renders in the DM.
1318// Returns null when there's no Eleven to DM (the tour then just stays in place).
1319// shell.js injects the implementation (roster + DM create + open live there).
1320let elevenTourFn = null;
1321export function setElevenTour(fn) {
1322 elevenTourFn = fn;
1323}
1324export function elevenTour() {
1325 if (!elevenTourFn) return Promise.resolve(null);
1326 return Promise.resolve()
1327 .then(() => elevenTourFn())
1328 .catch(() => null);
1329}
1330
1331// ---- conversational answers ----------------------------------------------
1332//
1333// A scripted conversation (the setup flow) can ask a question and take the next
1334// thing typed in that thread's composer as the answer. The message still sends
1335// normally — the answer is a real chat bubble in the transcript — the waiter is
1336// just told what it said. One waiter per thread; asking again replaces it.
1337const answerWaiters = new Map(); // threadId → fn(text, respond)
1338
1339export function awaitAnswer(threadId, fn) {
1340 answerWaiters.set(threadId, fn);
1341}
1342// The label ("Your name:") is presentation for the waiter, but they live in
1343// different modules — chat.js registers this hook so EVERY cancelled ask
1344// clears its labelled composer too (#1375): the dismiss X, a chip's
1345// cancelInput, the folded re-derive. Before the hook, a cancelInput chip
1346// stranded the pill until the next send.
1347let answerCancelledHook = null;
1348export function setAnswerCancelledHook(fn) {
1349 answerCancelledHook = fn;
1350}
1351export function cancelAnswer(threadId) {
1352 answerWaiters.delete(threadId);
1353 if (answerCancelledHook) answerCancelledHook(threadId);
1354}
1355// Whether a script sits parked on a question here — the setup flow's folded
1356// hook uses it to tell "parked, needs a re-derive" from "mid-run or ended".
1357export function hasAnswerWaiter(threadId) {
1358 return answerWaiters.has(threadId);
1359}
1360// Called by the chat app after a message actually sent. Consumes the waiter —
1361// a follow-up question re-arms it — and returns whether one was listening.
1362// `payload` is the sent message ({ text, attachments }) so a script can also
1363// take a PHOTO as the answer (the setup flow's server-photo step); a bare
1364// string is accepted for convenience.
1365export function deliverAnswer(threadId, payload, respond) {
1366 const fn = answerWaiters.get(threadId);
1367 if (!fn) return false;
1368 answerWaiters.delete(threadId);
1369 const p = typeof payload === "string" ? { text: payload } : payload || {};
1370 fn(p.text || "", p, respond);
1371 return true;
1372}
1373
1374// ---- setup bridge ----------------------------------------------------------
1375//
1376// The first-run setup conversation (apps/setup.js) is a HOST-LEVEL script like
1377// the tour — it writes real instance settings, which no mini-app may do (apps
1378// never fetch). shell.js injects these capabilities; the script only ever talks
1379// to this bridge. Null until a host provides it (e.g. Node tests, native embed).
1380let setupBridgeImpl = null;
1381export function setSetupBridge(b) {
1382 setupBridgeImpl = b;
1383}
1384export function setupBridge() {
1385 return setupBridgeImpl;
1386}
1387
1388function makeCtx(threadId, app) {
1389 return {
1390 threadId,
1391 // Emit a typed event into the thread (encrypted + sent as a normal message).
1392 // The transport injects `name` (your screen name), `ts` (send time) and `uid`
1393 // (your user id) onto every event, so a handler can read e.name/e.ts/e.uid
1394 // without the emitter setting them.
1395 emit: (event) => (transport ? transport(threadId, event) : Promise.resolve(false)),
1396 // A stable unique id for app entities (todo items, etc). Mint one per new
1397 // entity in the emit; later events (done/remove) reuse that id.
1398 id: () => crypto.randomUUID(),
1399 // Namespace resolvers bound to THIS app: ctx.ev("add") → "todo.add",
1400 // ctx.cls("item") → "todo-item". The same strings the app's `events` map
1401 // declares (via the module-level namespace()) — one source of truth for the
1402 // prefix, so a render/command reference can never drift from the fold's keys.
1403 ev: app.ns.ev,
1404 cls: app.ns.cls,
1405 // The current user's id — for per-user logic (whose vote, etc).
1406 me: currentUser,
1407 // Your screen name — lets an app recognise your own legacy (uid-less)
1408 // messages by name, mirroring ownMessage()'s fallback in the original feed.
1409 meName: currentUserName,
1410 // Latest state: a derived read over the app's folded event log. No second
1411 // store — two devices folding the same events converge by construction.
1412 getState: () => appStateFor(threadId, app.name),
1413 // Schedule an event for future delivery BY THE SERVER: at `whenMs` (epoch
1414 // ms) the event arrives in this thread like any other — folded everywhere,
1415 // notified via the app's notify hook, pushed to closed apps (give the event
1416 // a `notice: {title, body}` for that). Returns a schedule id to cancel with,
1417 // or null. Store the id in an emitted event so ANY device can cancel it.
1418 schedule: (event, whenMs) =>
1419 schedTransport ? schedTransport.schedule(threadId, event, whenMs) : Promise.resolve(null),
1420 // Cancel a pending scheduled event by the id schedule() returned.
1421 unschedule: (sid) => (schedTransport && sid ? schedTransport.cancel(sid) : Promise.resolve(false)),
1422 // Emit a relay-only event: delivered live to the thread's members (including
1423 // your own echo), never stored or folded. For presence traffic — the
1424 // receiving app handles it in its `ephemeral(event, ctx)` hook.
1425 emitEphemeral: (event) =>
1426 ephemeralTransport ? ephemeralTransport(threadId, event) : Promise.resolve(false),
1427 // Start a ticker the framework stops for you when the surface is redrawn or
1428 // the thread is reset — no timer bookkeeping in the app (used by render only).
1429 interval: (fn, ms) => {
1430 if (typeof setInterval === "undefined") return null;
1431 const id = setInterval(fn, ms);
1432 addCleanup(threadId, app.name, () => clearInterval(id));
1433 return id;
1434 },
1435 // Register any teardown to run before the next render / on thread reset.
1436 onCleanup: (fn) => addCleanup(threadId, app.name, fn),
1437 // Where THIS ctx's copy lives — "you" (the app's own thread), "chat" (a
1438 // shared copy in a conversation), or "space" (the one space-wide
1439 // instance). Apps may word replies with it (todo names the destination
1440 // list); the framework stamps routed replies with the place regardless
1441 // (see runCommand), so the disclosure never depends on app cooperation.
1442 place: threadId === spaceThreadId() ? "space" : threadOwnerApp(threadId) === app ? "you" : "chat",
1443 // Show this app instance — for an action chip a command offers, so moving
1444 // out of the current context is always the user's tap. Routed per place
1445 // (see setInstanceOpener); the bare thread-open is only a degraded
1446 // fallback for hosts that never inject the opener.
1447 open: () => (instanceOpenFn ? instanceOpenFn(app.name, threadId) : openThreadFn ? openThreadFn(threadId) : undefined),
1448 // Privileged host capabilities (session keypair / roster / fetch), injected
1449 // by shell.js — narrow by design (see setHostBridge). null in the native embed.
1450 host: hostBridge,
1451 };
1452}
1453
1454// ---- commands -----------------------------------------------------------
1455
1456// Bot-floor commands the CLIENT now answers directly (not the bot), with a private
1457// "only visible to you" notice — so they work in EVERY thread, bot present or not.
1458// They still live in BOT_COMMANDS (the sidecar keeps its own handler + the "/"
1459// typeahead lists them), but runCommand catches them before sending, and
1460// commandCatalog flags them non-bot so the typeahead shows them everywhere.
1461const CLIENT_ANSWERED = new Set(["/help"]);
1462
1463// Floor commands the CLIENT answers in place, owned by no app and never sent to a
1464// bot (like /help, but contributed by a feature module — e.g. /tutorial). Each is
1465// run(args, threadId, reply), rendering private notices via reply. Kept apart from
1466// BOT_COMMANDS so the sidecar never sees a command it has no handler for, and
1467// listed in the "/" typeahead everywhere (bot:false), like the client-answered set.
1468const floorCommands = new Map(); // name -> { desc, args, run(args, threadId, reply) }
1469export function registerFloorCommand(name, spec) {
1470 floorCommands.set(name, { desc: (spec && spec.desc) || "", args: (spec && spec.args) || "", run: spec && spec.run });
1471}
1472
1473// helpNotice lists the commands you can use, as the private "/help" reply. Mirrors
1474// the typeahead: the bot's commands (/theme, …) show only where a bot can answer
1475// them, so /help never advertises a command that would do nothing here.
1476function helpNotice(threadId) {
1477 const hasBot = threadHasBotFn ? !!threadHasBotFn(threadId) : false;
1478 const lines = commandCatalog(threadId)
1479 // Alt rows (the "@me"/"@everyone" variants the typeahead offers) would
1480 // triple some verbs here — the closing sentence teaches the flags once.
1481 .filter((c) => (!c.bot || hasBot) && !c.alt)
1482 .map((c) => `${c.name}${c.args ? " " + c.args : ""} — ${c.desc}`);
1483 // The one routing sentence (the "places" grammar) — /help teaches the same
1484 // rule the typeahead labels disclose per keystroke, in the same words. The
1485 // space clause and @everyone only teach while some listed app can actually
1486 // live there: with shipped apps parked at you+chat (docs/space-apps.md
1487 // § The pullback), mentioning @everyone would teach a dead end.
1488 const spacey = listedApps().some((a) => a.places.includes("space"));
1489 const text = lines.length
1490 ? "Commands you can use:\n" +
1491 lines.join("\n") +
1492 `\n\nA command acts on the nearest copy of its app — this chat's${spacey ? ", then the space's" : ""}, then yours. Each line above names where it lands. Point one at a place yourself with ${ME_FLAG}${spacey ? `, ${HERE_FLAG}, or ${EVERYONE_FLAG}` : ` or ${HERE_FLAG}`} — e.g. /todo ${ME_FLAG} dentist.`
1493 : "No commands are available here yet.";
1494 return { sender: defaultBotFn ? defaultBotFn() : null, text };
1495}
1496
1497// parseCommand pulls the command out of "/todo buy milk" → {name:"/todo",
1498// args:"buy milk", app, local}. `app` is the owning app, or null when the text
1499// is "/word" but no app owns it (a FAILED command — the caller answers with an
1500// "unknown command" reply rather than broadcasting it). Returns null only when
1501// the text isn't a command attempt at all: no leading "/", or a bare "/" / "/ …"
1502// (a lone slash is punctuation, sent as normal text). Pass the thread the text
1503// was typed in as `threadId` and the owning app's LOCAL commands resolve there
1504// first ("/add" in the To-do thread) — without it, only global commands match.
1505export function parseCommand(text, threadId) {
1506 if (!text || text[0] !== "/") return null;
1507 const m = /^\/(\S+)/.exec(text);
1508 if (!m) return null; // "/" alone or "/ …" → literal text, not a command
1509 const name = "/" + m[1];
1510 const args = text.slice(name.length).trim();
1511 const owner = threadId !== undefined ? threadOwnerApp(threadId) : null;
1512 if (owner && owner.commands[name] && owner.commands[name].local) {
1513 return { name, args, app: owner, local: true };
1514 }
1515 return { name, args, app: commandOwner.get(name) || null, local: false };
1516}
1517
1518// defaultCommandFor: the command that plain (non-slash) text typed into
1519// `threadId` should run — { app, name, spec } — or null when the thread isn't
1520// an app-thread, the app declares no defaultCommand, or a bot member is present
1521// (an LLM answering the thread makes plain text a real message again, so the
1522// implicit routing steps aside). This is the whole "typing into To-do adds a
1523// to-do" decision, kept pure so web, tests, and native mirror one rule.
1524export function defaultCommandFor(threadId) {
1525 const app = threadOwnerApp(threadId);
1526 if (!app || !app.defaultCommand) return null;
1527 const spec = app.commands[app.defaultCommand];
1528 if (!spec || !spec.run) return null;
1529 if (threadHasBotFn && threadHasBotFn(threadId)) return null;
1530 return { app, name: app.defaultCommand, spec };
1531}
1532
1533// ---- @-mentioning a group app --------------------------------------------
1534//
1535// In a chat that has apps added, "@To-do buy milk" runs the app's DEFAULT
1536// command with the rest of the text: the mention is the address, the text is
1537// the action, and what lands in the thread is the EVENT (folded by every
1538// member) — not a message. The same "the message becomes the action" idea as
1539// defaultCommand in a personal app-thread, made explicit because a group's
1540// plain text is real conversation that an app must never eat (see CLAUDE.md).
1541
1542// mentionableApps: the apps "@App …" can address in a thread — the chat's own
1543// added apps first (the nearer scope), then the space-wide installs, deduped.
1544// A space-installed app is mentionable in EVERY chat; its default command acts
1545// on the shared space instance (see runAppMention's target resolution).
1546// Exported for the shell's "@" typeahead, so what's suggested and what
1547// resolves stay one list.
1548export function mentionableApps(threadId) {
1549 const out = [...groupAppsFor(threadId)];
1550 for (const a of spaceAppsFor()) {
1551 if (!out.some((g) => g.name === a.name)) out.push(a);
1552 }
1553 return out;
1554}
1555
1556// parseAppMention pulls the addressed app out of leading-@ text —
1557// "@To-do buy milk" → { app, args: "buy milk" } — or null when the text doesn't
1558// address an added app. Matches an ADDED app (this thread's groupapps state, or
1559// a space-wide install) by title or name, case-insensitively, and requires the
1560// mention to break on whitespace so "@To-dos…" never matches To-do. Pure, so
1561// it's testable in Node.
1562export function parseAppMention(text, threadId) {
1563 if (!text || text[0] !== "@") return null;
1564 for (const app of mentionableApps(threadId)) {
1565 if (!app.defaultCommand) continue;
1566 for (const label of [app.title, app.name]) {
1567 if (!label) continue;
1568 if (text.slice(1, 1 + label.length).toLowerCase() !== label.toLowerCase()) continue;
1569 const rest = text.slice(1 + label.length);
1570 if (rest && !/^\s/.test(rest)) break; // mid-word — not this app's mention
1571 return { app, args: rest.trim() };
1572 }
1573 }
1574 return null;
1575}
1576
1577// runAppMention executes "@App <text>" typed in `threadId`: the app's default
1578// command runs with ctx bound to THAT thread, so the event lands in the group
1579// log. Returns true when the text addressed an added app — the composer then
1580// clears instead of sending a message. Confirmation replies are suppressed
1581// (the folded event already shows as an action line — the signal is `short`,
1582// see noticeCalmsDown); content replies (a usage line for a bare "@To-do")
1583// still render privately where you typed, via respond.
1584export function runAppMention(text, threadId, respond) {
1585 const m = parseAppMention(text, threadId);
1586 if (!m) return false;
1587 const spec = m.app.commands[m.app.defaultCommand];
1588 if (!spec || !spec.run) return false;
1589 const label = m.app.title || m.app.name;
1590 const sender = { name: label, glyph: m.app.glyph, logo: m.app.logo, kind: "app" };
1591 const reply = (r) => {
1592 try {
1593 respond && respond({ sender, ...r });
1594 } catch (e) {
1595 console.error(e);
1596 }
1597 };
1598 // A bare mention of a command that expects arguments gets the usage line in
1599 // MENTION form — the command's own usage would name a slash verb (/add) that
1600 // doesn't resolve in this thread.
1601 if (!m.args && spec.args) {
1602 reply({ text: `Usage: @${label} ${spec.args}` });
1603 return true;
1604 }
1605 // The mention acts on the NEAREST copy, mirroring instanceThread's order
1606 // synchronously (runAppMention's boolean return gates the composer, so it
1607 // can't await): this chat's instance when added here, else the space-wide
1608 // one mentionableApps admitted. Panels and replies stay where you typed.
1609 const target = groupAppsFor(threadId).some((a) => a.name === m.app.name) ? threadId : spaceThreadId();
1610 if (!target) return false;
1611 const ctx = makeCtx(target, m.app);
1612 ctx.reply = (r) => {
1613 if (noticeCalmsDown(r)) return; // the action line in the feed is the record
1614 // A mention routed to the space instance labels its (content/error)
1615 // replies like a routed slash command does — same disclosure, same words.
1616 reply(target !== threadId ? { place: ctx.place, ...r } : r);
1617 };
1618 // Mentions get the same transient-panel doorway a slash command does — the
1619 // mention is typed in this thread, so the panel opens right here.
1620 ctx.panel = (s) => openCommandPanel(threadId, panelSpecFor(m.app, ctx, sender, s));
1621 ctx.closePanel = () => closeCommandPanel(threadId);
1622 Promise.resolve(spec.run(m.args, ctx)).catch((e) => console.error(e));
1623 return true;
1624}
1625
1626// ---- place flags: the @-keywords that aim a command at a place -------------
1627//
1628// ONE spot for the spellings, so playing with the words is a one-line change
1629// (@everyone is a lot to type — @all? @space?): every surface that SHOWS a
1630// flag (the typeahead's alt rows, /help's closing sentence, refusal copy)
1631// reads the canonical constant, and the parsers accept it plus its aliases.
1632// If a canonical spelling ever changes, move the old one into the alias list
1633// so members' muscle memory keeps working.
1634export const HERE_FLAG = "@here";
1635export const ME_FLAG = "@me";
1636export const EVERYONE_FLAG = "@everyone";
1637// Accepted on parse, never shown — some people will guess the place's own name.
1638export const EVERYONE_ALIASES = ["@space"];
1639// Only a LEADING flag counts, and it must break on whitespace ("@heresy" is
1640// just text): matched flag → the rest of the args in group 1.
1641const flagRe = (...flags) => new RegExp("^(?:" + flags.join("|") + ")(?:\\s+([\\s\\S]*))?$");
1642const HERE_RE = flagRe(HERE_FLAG);
1643const EVERYONE_RE = flagRe(EVERYONE_FLAG, ...EVERYONE_ALIASES);
1644const ME_RE = flagRe(ME_FLAG);
1645
1646// parseHere pulls the "@here" flag off a command's arguments — the general
1647// pattern "/todo @here buy milk": the flag addresses THIS chat's shared
1648// instance, installing the app first if it isn't added yet (see runCommand).
1649// Returns { rest } with the remaining args, or null when the flag isn't
1650// present. Pure, so it's testable in Node.
1651export function parseHere(args) {
1652 const m = HERE_RE.exec((args || "").trim());
1653 return m ? { rest: (m[1] || "").trim() } : null;
1654}
1655
1656// parseEveryone is @here's space-wide sibling: "/stickers @everyone" installs
1657// the app for the WHOLE space (a bare flag is just the install; trailing text
1658// runs the command against the fresh space instance). Pure, so it's testable
1659// in Node.
1660export function parseEveryone(args) {
1661 const m = EVERYONE_RE.exec((args || "").trim());
1662 return m ? { rest: (m[1] || "").trim() } : null;
1663}
1664
1665// parseMeFlag completes the family toward PRIVACY: "/todo @me dentist" files
1666// into YOUR copy even where a chat or space copy claims the bare verb — the
1667// escape hatch points at the safe place. (Named -Flag to keep clear of
1668// parseMeCommand, the "/me waves" emote.) Pure, so it's testable in Node.
1669export function parseMeFlag(args) {
1670 const m = ME_RE.exec((args || "").trim());
1671 return m ? { rest: (m[1] || "").trim() } : null;
1672}
1673
1674// ---- command panels: a transient surface a command opens ------------------
1675//
1676// A command can answer with more than a private reply: it can open a PANEL — a
1677// temporary, interactive surface in the chat you TYPED in (just above the
1678// composer by default, or pinned above the feed with position: "top"). It's
1679// the richer sibling of the reply, still in place: /gif would show a grid of
1680// results, tapping one attaches it and dismisses the panel. A panel is
1681// client-only chrome — never sent, never folded, gone on dismissal or when the
1682// pane unmounts — so it carries no events and no durable state. One panel per
1683// thread: opening another replaces it. The USER dismisses it (its ✕, or
1684// Escape) or the APP does (panel.close()). The chat app injects the host that
1685// actually draws it; openCommandPanel returns false when the thread has no
1686// mounted chat surface, so a command can fall back to a plain reply.
1687let panelHost = null; // (threadId, spec|null) => boolean — the chat app injects it
1688export function setCommandPanelHost(fn) {
1689 panelHost = fn;
1690}
1691export function openCommandPanel(threadId, spec) {
1692 if (!panelHost || !spec || typeof spec.render !== "function") return false;
1693 return !!panelHost(threadId, spec);
1694}
1695export function closeCommandPanel(threadId) {
1696 if (panelHost) panelHost(threadId, null);
1697}
1698// A host-side observer of panel visibility, fired by the HOST's controller for
1699// every show and dismiss — command open, replacement, the ✕, Escape, and the
1700// app's own panel.close() all land here, so it's the one reliable signal that
1701// the panel's box appeared or went away. The command-host embed (the native
1702// Mac shell's panel webview) uses it to tell the shell to reveal or collapse
1703// the webview around the panel.
1704let panelObserver = null; // (threadId, spec|null) => void
1705export function setCommandPanelObserver(fn) {
1706 panelObserver = fn;
1707}
1708// Additive observers, for host chrome that mirrors panel state without owning
1709// it (the shell's Views button names whatever the sidebar is showing). The
1710// single set-slot above stays the command-host embed's — the two never fight.
1711const panelObservers = new Set();
1712export function addCommandPanelObserver(fn) {
1713 panelObservers.add(fn);
1714}
1715export function notifyCommandPanel(threadId, spec) {
1716 for (const fn of panelObserver ? [panelObserver, ...panelObservers] : [...panelObservers]) {
1717 try {
1718 fn(threadId, spec || null);
1719 } catch (e) {
1720 console.error(e);
1721 }
1722 }
1723}
1724
1725// panelSpecFor resolves a command's panel request to the host spec. Two forms:
1726// a `render` closure (the trusted-world original), or the DECLARATIVE form —
1727// ctx.panel({ title, data }) plus a top-level `panel(data, el, panelCtx)` hook
1728// on the app. The declarative form is the one the member-app sandbox supports
1729// (a closure can't cross its bridge), and it works identically here, so one
1730// app file runs compiled-in or uploaded. panelCtx = the host handle (close /
1731// insertText / …) plus the app ctx's emit/id/me/getState.
1732function panelSpecFor(app, ctx, sender, spec) {
1733 const s = { sender, ...spec };
1734 if (typeof s.render !== "function" && app && typeof app.panel === "function") {
1735 const data = s.data === undefined ? null : s.data;
1736 s.render = (el, handle) =>
1737 app.panel(data, el, { ...handle, emit: ctx.emit, id: ctx.id, me: ctx.me, getState: ctx.getState });
1738 }
1739 return s;
1740}
1741
1742// /me is unlike every other command: it BROADCASTS a real message (an
1743// action-style line, `{text, action:true}`) rather than answering privately,
1744// so the composer (chat.js) handles it directly, before runCommand ever sees
1745// the text. parseMeCommand pulls the action out of "/me waves" → "waves" (or
1746// "" for a bare "/me"), or null when the text isn't a /me line at all — kept
1747// pure + exported so it's unit-tested without a DOM.
1748export function parseMeCommand(text) {
1749 const m = /^\/me(?:\s+([\s\S]*))?$/i.exec((text || "").trim());
1750 return m ? (m[1] || "").trim() : null;
1751}
1752
1753// runCommand executes a slash command, answering IN PLACE via respond(response)
1754// — a private "only visible to you" reply the caller renders in the CURRENT
1755// thread's feed (see buildLocalNotice). Returns true synchronously whenever the
1756// text was a command attempt (registered OR not), so the composer clears and a
1757// mistyped "/foo" is never broadcast to the thread. A personal app's command is
1758// routed to the user's own app-thread (created on first use) no matter which
1759// chat it was typed in; a shared app's command targets the current chat. The
1760// handler answers with ctx.reply(...); it never navigates — moving out of
1761// context is only ever an action chip the user taps (see CLAUDE.md).
1762export function runCommand(text, activeThreadId, respond) {
1763 const cmd = parseCommand(text, activeThreadId);
1764 if (!cmd) return false; // not a command attempt → let the composer send it
1765 const reply = (r) => {
1766 try {
1767 respond && respond(r);
1768 } catch (e) {
1769 console.error(e);
1770 }
1771 };
1772 if (!cmd.app) {
1773 // A registered floor command (e.g. /tutorial) answers itself, in place.
1774 const floor = floorCommands.get(cmd.name);
1775 if (floor && floor.run) {
1776 floor.run(cmd.args, activeThreadId, reply);
1777 return true;
1778 }
1779 // /help (and any other CLIENT_ANSWERED floor command) is answered right here
1780 // with a private notice — no bot needed, so it works in every thread.
1781 if (cmd.name === "/help") {
1782 reply(helpNotice(activeThreadId));
1783 return true;
1784 }
1785 // Any other bot command (/theme, …) is SENT so the bot answers it: return false
1786 // to let the composer post it. Only a truly unknown "/word" is caught + answered.
1787 if (BOT_COMMANDS[cmd.name]) return false;
1788 // In an app's own thread the app answers for itself; elsewhere Eleven does.
1789 const owner = threadOwnerApp(activeThreadId);
1790 const sender = owner ? { name: owner.title || owner.name, glyph: owner.glyph, logo: owner.logo, kind: "app" } : defaultBotFn ? defaultBotFn() : null;
1791 reply({ sender, text: `${cmd.name} isn't a command. Type / to see what's available.` });
1792 return true; // swallow — a failed command is answered, not broadcast
1793 }
1794 const app = cmd.app;
1795 (async () => {
1796 let target = activeThreadId;
1797 let args = cmd.args;
1798 const sender = { name: app.title || app.name, glyph: app.glyph, logo: app.logo, kind: "app" };
1799 // "/todo @here buy milk" — the @here flag addresses THIS chat's shared
1800 // instance, INSTALLING the app into the group first if needed (the same
1801 // groupapps.add the Add-apps sheet emits, so everyone gets the widget bar
1802 // and the action line). A bare "/todo @here" is just the install. Only
1803 // meaningful for a group-capable personal app in a group — anywhere else
1804 // it answers with a private explanation rather than mis-filing the text.
1805 // Any chat-placeable OR personal app parses the flag: for a chat-only
1806 // app (Poll) @here is redundant — its bare verb already adopts into the
1807 // group — but a redundant flag must be STRIPPED, never folded into the
1808 // user's content ("@here Pizza?" is not a poll title).
1809 const here = !cmd.local && (app.places.includes("you") || app.places.includes("chat")) && parseHere(args);
1810 // "/stickers @everyone …" — @here's space-wide sibling: install the app
1811 // for the WHOLE space (creating the space thread on first use), then run
1812 // any trailing text against the shared space instance. Works from any
1813 // chat — that's the point — and answers with a private explanation when
1814 // the app can't be space-installed or the keys aren't ready yet.
1815 const everyone = !cmd.local && !here && parseEveryone(args);
1816 // "/todo @me …" — the family's third member, pointing at PRIVACY: file
1817 // into your own copy even where a chat or space copy claims the bare
1818 // verb. No install side effects ever (the personal thread is created on
1819 // demand, which is already the bare verb's behaviour when nothing nearer
1820 // exists); a bare "@me" just gets the command's own usage reply.
1821 const meFlag = !cmd.local && !here && !everyone && parseMeFlag(args);
1822 if (meFlag) {
1823 args = meFlag.rest;
1824 if (!app.places.includes("you")) {
1825 reply({ sender, text: app.placeNote || `${app.title || app.name} doesn't keep a just-for-you copy.` });
1826 return;
1827 }
1828 target = resolveAppThread ? await resolveAppThread(app.name) : null;
1829 if (!target) return;
1830 } else if (here) {
1831 args = here.rest;
1832 if (!app.places.includes("chat")) {
1833 reply({ sender, text: `${app.title || app.name} can't be shared to a chat.` });
1834 return;
1835 }
1836 if (!(threadIsGroupFn && threadIsGroupFn(activeThreadId))) {
1837 reply({ sender, text: `${HERE_FLAG} shares an app with a group — it only works in a group chat.` });
1838 return;
1839 }
1840 if (!(await installGroupApp(activeThreadId, app))) {
1841 reply({ sender, text: `Couldn't add ${app.title || app.name} — check your connection and try again.` });
1842 return;
1843 }
1844 if (!args) return; // just the install — the "added … to this chat" action line is the record
1845 // target stays THIS chat: the event goes to the shared instance.
1846 } else if (everyone) {
1847 args = everyone.rest;
1848 if (!app.places.includes("space")) {
1849 reply({ sender, text: `${app.title || app.name} can't be installed for the whole space.` });
1850 return;
1851 }
1852 if (!spaceInstallPolicy().canInstall) {
1853 reply({ sender, text: `Only admins can add apps for everyone in this space.` });
1854 return;
1855 }
1856 if (!(await installSpaceApp(app))) {
1857 reply({ sender, text: `Couldn't install ${app.title || app.name} — check your connection and try again.` });
1858 return;
1859 }
1860 if (!args) {
1861 reply({
1862 sender,
1863 text: `${app.title || app.name} is added for everyone in this space — its commands now work in every chat.`,
1864 short: `added for everyone`,
1865 // Leaving the chat stays the user's tap (commands answer in place)
1866 // — but the new home is one chip away, like every "View <app>".
1867 ...(app.render
1868 ? { actions: [{ label: `View ${app.title || app.name}`, run: () => (instanceOpenFn ? instanceOpenFn(app.name, spaceThreadId()) : undefined) }] }
1869 : {}),
1870 });
1871 return;
1872 }
1873 target = spaceThreadId(); // the trailing text acts on the space instance
1874 } else {
1875 // A LOCAL command only exists in the thread it was typed in — it targets
1876 // that thread directly, no routing. A global personal command routes to
1877 // its own app-thread from anywhere — UNLESS a nearer shared instance
1878 // claims the verb: the app's OWN thread wins outright (the same verb
1879 // always addresses the instance you're looking at), then an app ADDED
1880 // to the chat you're typing in (groupAppsFor), so "/todo" there files
1881 // into the GROUP list, exactly like "@To-do"; failing those, an app
1882 // INSTALLED FOR THE SPACE (spaceAppsFor) wins from every chat — one
1883 // shared instance, which is what "installed for everyone" means. Your
1884 // personal list stays reachable from its own thread.
1885 target = await instanceThread(app, activeThreadId, { local: cmd.local });
1886 // A CHAT-PLACE-ONLY app (no personal copy — poll, whiteboard) acts in
1887 // the chat it was typed in, so using it IS adopting it: auto-add the
1888 // app to this group's registry when missing — the same groupapps.add
1889 // the sheet emits, so everyone gets the action line and the widget
1890 // bar. Groups only: registry adds are a group act (the sheet's rule),
1891 // and elsewhere the command still answers in place.
1892 if (
1893 !cmd.local &&
1894 !app.places.includes("you") &&
1895 app.places.includes("chat") &&
1896 target === activeThreadId &&
1897 threadIsGroupFn &&
1898 threadIsGroupFn(activeThreadId) &&
1899 !groupAppsFor(activeThreadId).some((a) => a.name === app.name)
1900 ) {
1901 await installGroupApp(activeThreadId, app);
1902 }
1903 }
1904 if (!target) {
1905 // No copy of this app is reachable, so there is nowhere for the command to
1906 // act: a personal app's thread couldn't be created, or — for an implicitly
1907 // space-placed app — the space thread isn't here (a native embed never
1908 // loads it; a member's instance key may still be syncing). This used to
1909 // return silently, which reads as a broken command; naming it is cheap and
1910 // true for every app.
1911 reply({ sender, text: `${cmd.name} isn't available here yet.` });
1912 return;
1913 }
1914 const ctx = makeCtx(target, app);
1915 // ctx.reply is the private "only visible to you" reply, rendered where you
1916 // TYPED. When you're already IN the app's thread, CONFIRMATIONS are skipped
1917 // — the event itself lands as a visible action line, so echoing it would be
1918 // redundant — but a reply with no `short` is content or an error ("when
1919 // should I remind you?"), the command's only feedback, so it always shows.
1920 // "Has a short" == "is a transient confirmation" (see noticeCalmsDown).
1921 ctx.reply = (r) => {
1922 if (target === activeThreadId && r && r.short) return;
1923 // A ROUTED reply says where it acted (the typeahead's vocabulary — see
1924 // placePhrase): the reply renders where you typed, but the data landed
1925 // in another place, and naming it is the whole disclosure. Stamped by
1926 // the framework so it never depends on the app's own wording — member-
1927 // built apps get it for free. (The app may still override `place`.)
1928 reply({ sender, ...(target !== activeThreadId ? { place: ctx.place } : {}), ...r });
1929 };
1930 // ctx.panel opens a transient results surface where you TYPED (like
1931 // ctx.reply, it's bound to the active thread, not the routed target) —
1932 // see the command-panels section above. Returns false when this thread
1933 // can't draw one, so the command can answer with a reply instead.
1934 ctx.panel = (spec) => openCommandPanel(activeThreadId, panelSpecFor(app, ctx, sender, spec));
1935 ctx.closePanel = () => closeCommandPanel(activeThreadId);
1936 await app.commands[cmd.name].run(args, ctx);
1937 })();
1938 return true;
1939}
1940
1941// The registered commands as { name, desc, args, bot } — feeds the "/" typeahead
1942// and /help. Pass the thread being typed in as `threadId` and the list becomes
1943// contextual: inside an app's own thread its LOCAL commands lead (they're the
1944// verbs that act right here), and the app's global routing aliases (/todo in the
1945// To-do thread) are dropped as redundant. Other apps' global commands stay — they
1946// work from anywhere by design. The caller may hide `bot` ones in threads with no
1947// bot (see shell.js typeaheadItems).
1948export function commandCatalog(threadId) {
1949 const owner = threadId !== undefined ? threadOwnerApp(threadId) : null;
1950 const out = [];
1951 let ownerHasLocals = false;
1952 if (owner) {
1953 for (const [name, c] of Object.entries(owner.commands)) {
1954 if (!c.local) continue;
1955 ownerHasLocals = true;
1956 out.push({ name, desc: c.desc, args: c.args, bot: false });
1957 }
1958 // No locals → the app's global verbs ARE its in-thread verbs (Reminders'
1959 // /remind), so they lead here instead of dropping out.
1960 if (!ownerHasLocals) {
1961 for (const [name, c] of Object.entries(owner.commands)) {
1962 out.push({ name, desc: c.desc, args: c.args, bot: false });
1963 }
1964 }
1965 }
1966 for (const [name, app] of commandOwner.entries()) {
1967 // In its own thread the app already led with its verbs above: locals
1968 // supersede its global aliases (/todo duplicates /add), and lead-listed
1969 // globals must not repeat.
1970 if (owner && app === owner) continue;
1971 let desc = (app.commands[name] && app.commands[name].desc) || "";
1972 // Say WHERE the command's data will land — EVERY app command names its
1973 // place, mirroring runCommand's routing exactly (the "places" grammar,
1974 // docs/space-apps.md): this chat's shared copy, the space-wide copy, or
1975 // just for you, worded to read on from the desc as one sentence
1976 // (placeRowPhrase). Utility commands that keep no state anywhere (no
1977 // personal instance, not shared here or space-wide) stay unlabelled:
1978 // they act in place, so there is no place to name.
1979 const baseDesc = desc;
1980 let place = "";
1981 const addedHere = threadId !== undefined && groupAppsFor(threadId).some((a) => a.name === app.name);
1982 const inSpace = app.places.includes("space") && spaceAppsFor().some((a) => a.name === app.name);
1983 const inGroup = threadId !== undefined && threadIsGroupFn && threadIsGroupFn(threadId);
1984 if (addedHere) place = "chat";
1985 else if (inSpace) place = "space";
1986 else if (app.places.includes("you")) place = "you";
1987 if (desc && place) desc += " " + placeRowPhrase(place);
1988 const cmdArgs = (app.commands[name] && app.commands[name].args) || "";
1989 out.push({
1990 name,
1991 desc,
1992 args: cmdArgs,
1993 bot: false,
1994 // Where the command's data lands ("chat" | "space" | "you" | "") — the
1995 // typeahead ranks nearer places first (the places grammar).
1996 place,
1997 });
1998 // Offer the same verb with a place flag as extra rows — the typeahead is
1999 // where the flags are taught: nobody reads docs, everybody reads the menu.
2000 // Two flavours, told apart by their phrase: a copy that already exists
2001 // gets an ADDRESSING row (placeRowPhrase — where the text will land); a
2002 // place the flag would INSTALL INTO says so (installRowPhrase — "to a new
2003 // To-do app for this group"), so an install side effect is never hidden
2004 // behind the routing wording. Alt rows carry sortPlace = the bare verb's
2005 // place, so the typeahead's nearness sort keeps each alternate right
2006 // behind its default instead of ranking an install offer above it.
2007 const alt = (flag, phrase, altPlace) =>
2008 out.push({ name: `${name} ${flag}`, desc: baseDesc ? baseDesc + " " + phrase : "", args: cmdArgs, bot: false, place: altPlace, sortPlace: place, alt: true });
2009 if (place === "chat" || place === "space") {
2010 if (place === "chat" && inSpace) alt(EVERYONE_FLAG, placeRowPhrase("space"), "space");
2011 if (app.places.includes("you")) alt(ME_FLAG, placeRowPhrase("you"), "you");
2012 } else if (place === "you") {
2013 // The fresh case — nothing installed anywhere, the bare verb files
2014 // personally. The flags are how you PUT the app somewhere, so the rows
2015 // double as the install hint the old "— @here to share" suffix carried,
2016 // now covering the whole family and selectable like any other row.
2017 if (inGroup && app.places.includes("chat")) alt(HERE_FLAG, installRowPhrase(app.title || app.name, "chat"), "chat");
2018 if (app.places.includes("space") && app.listed && spaceInstallPolicy().canInstall) alt(EVERYONE_FLAG, installRowPhrase(app.title || app.name, "space"), "space");
2019 }
2020 }
2021 // Floor commands (e.g. /tutorial) are client-answered → shown in every thread.
2022 for (const [name, c] of floorCommands) out.push({ name, desc: c.desc, args: c.args, bot: false });
2023 // /me is handled by the composer itself (see parseMeCommand) — listed here only
2024 // so the "/" typeahead and /help surface it, like the client-answered set.
2025 out.push({ name: "/me", desc: "post an action line, e.g. /me waves", args: "<action>", bot: false });
2026 // bot:false for the ones the client answers itself (/help) → the typeahead lists
2027 // them in every thread, not only where a bot is present.
2028 for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: !CLIENT_ANSWERED.has(name) });
2029 return out;
2030}
2031
2032// The registered command names (e.g. ["/todo"]).
2033export function commandNames() {
2034 return [...commandOwner.keys()];
2035}
2036
2037// commandTakenBy: who owns a slash command right now — a registered app's
2038// name, "host" for the floor/bot/composer set (/help, /me, /theme, …), or ""
2039// when the name is free. The member-app gates (sandbox.js) refuse taken
2040// names, mirroring the event-type collision check: without this a member
2041// app could register /todo AFTER the built-in (member apps load later) and
2042// silently shadow it in commandOwner — routing every member's typed
2043// arguments into a log the hijacker can read.
2044export function commandTakenBy(name) {
2045 const owner = commandOwner.get(name);
2046 if (owner) return owner.name || "an app";
2047 if (BOT_COMMANDS[name] || floorCommands.has(name) || name === "/me" || name === "/help") return "host";
2048 return "";
2049}
2050
2051// placePhrase: the STAND-ALONE vocabulary for naming a place — the routed
2052// reply's place footer and any surface that shows a place by itself (the
2053// places grammar, docs/space-apps.md). The "/" typeahead rows word places
2054// with the FLOWING forms below instead, because these don't concatenate
2055// after a command's desc. Pure, exported for tests.
2056export function placePhrase(place) {
2057 if (place === "you") return "just for you";
2058 if (place === "chat") return "shared in this chat";
2059 if (place === "space") return "for everyone in this space";
2060 return "";
2061}
2062
2063// placeRowPhrase: how a "/" typeahead row words a place — flowing on from the
2064// command's own desc, so the whole row reads as one sentence: "Add a to-do
2065// just for you", "Add a to-do for this group". (placePhrase's stand-alone
2066// "shared in this chat" would misread here — it sounds like the TO-DO is
2067// shared.) Pure, exported for tests.
2068export function placeRowPhrase(place) {
2069 if (place === "you") return "just for you";
2070 if (place === "chat") return "for this group";
2071 if (place === "space") return "for this space";
2072 return "";
2073}
2074
2075// installRowPhrase: the fresh-case sibling — the row's flag would CREATE the
2076// app's shared copy and file the command's action into it, and the row says
2077// exactly that ("Add a to-do to a new To-do app for this group"), so picking
2078// an install is never mistaken for mere routing. Pure, exported for tests.
2079export function installRowPhrase(title, place) {
2080 if (place !== "chat" && place !== "space") return "";
2081 return `to a new ${title} app ${placeRowPhrase(place)}`;
2082}
2083
2084// spaceRowTitle: what the sidebar calls a space copy — "<Space name> To-do" —
2085// so it never reads as a twin of the personal "To-do" beside it (two
2086// same-named rows confused even with the badge). The space name only brands
2087// the row when it's real and short enough to leave the app title visible;
2088// unset (callers pass "" for the compiled-in default) or long names fall back
2089// to the plain word: "Space To-do". Pure, exported for tests.
2090export function spaceRowTitle(appTitle, spaceName) {
2091 const n = (spaceName || "").trim();
2092 return `${n && n.length <= 20 ? n : "Space"} ${appTitle}`;
2093}
2094
2095// A CONFIRMATION reply is loud for a while, then calms down: after this long, the
2096// full card collapses to a one-line record. Exported + with pure sibling helpers
2097// so the decision is testable without a DOM.
2098export const NOTICE_COMPACT_AFTER_MS = 30000;
2099
2100// Only a confirmation calms down — a genuine content reply (like /help) is a real
2101// chat message and stays put. The signal is already in the reply: a confirmation
2102// gives a terse `short` (what it shrinks to, e.g. `added "milk"`); /help gives
2103// none. So "has a short" == "is a transient confirmation that counts down."
2104export function noticeCalmsDown(spec = {}) {
2105 return !!(spec && spec.short && String(spec.short).trim());
2106}
2107
2108// The two pieces the calmed-down one-liner shows: who answered, and a terse "what
2109// happened" — the reply's own `short` if it gave one (e.g. `added "milk"`), else
2110// its full text. Pure so it's unit-tested directly.
2111export function noticeParts({ sender, text, short } = {}) {
2112 return { from: (sender && sender.name) || "", what: (short || text || "").trim() };
2113}
2114
2115// Whether a notice created at `at` (epoch ms) should already render compact by
2116// `now` — true once it's aged past the threshold. A reply reopened long after it
2117// landed (its node rebuilt on thread reopen) thus starts compact, no timer.
2118export function noticeIsCompact(at, now) {
2119 return !!at && now - at >= NOTICE_COMPACT_AFTER_MS;
2120}
2121
2122function runNoticeAction(a) {
2123 try {
2124 a && a.run && a.run();
2125 } catch (e) {
2126 console.error(e);
2127 }
2128}
2129
2130// Whether animations are allowed (honours the OS "reduce motion" setting).
2131// Exported for surface hosts (the chat app's command panel eases open/shut).
2132export function motionOK() {
2133 try {
2134 return !(typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches);
2135 } catch {
2136 return true;
2137 }
2138}
2139
2140// The loud card, styled like a chat reply: the sender (+ AI/BOT badge) and the
2141// "Only visible to you" tag sit OUTSIDE the bubble (above it, like a message's
2142// name row), the reply text is a chat-style bubble, and any trusted action chips
2143// ({ label, run } — minted locally, so they bypass the untrusted-payload
2144// whitelist) sit below.
2145//
2146// Notice bodies render the same allowlisted CommonMark subset as chat messages
2147// (markdown.js, #531), so a command example in `backticks` shows as monospace
2148// code (and **bold**, links, lists etc. work too). renderTree builds DOM from
2149// text nodes + wrapper elements — never innerHTML, so untrusted text (a to-do
2150// name in a confirmation) stays inert. No mention hook here: notices are
2151// machine-authored, not member prose.
2152function renderNoticeBody(text, el) {
2153 renderTree(el, parseMessage(text));
2154}
2155
2156function fillFullNotice(row, spec = {}, opts = {}) {
2157 const { sender, text, actions } = spec;
2158 // A continuation row in a same-speaker run drops the name/badge (they live on
2159 // the run's first bubble, like regular message runs) — but NEVER the privacy
2160 // tag: outside a solo thread, every full private notice stays labelled, or a
2161 // continuation would read as a public message.
2162 const head = document.createElement("div");
2163 head.className = "local-notice-head";
2164 if (!opts.cont && sender && sender.name) {
2165 if (sender.logo || sender.glyph) {
2166 const g = document.createElement("span");
2167 g.className = "local-notice-glyph";
2168 appGlyphInto(g, sender);
2169 head.appendChild(g);
2170 }
2171 const nm = document.createElement("span");
2172 nm.className = "local-notice-from";
2173 nm.textContent = sender.name;
2174 head.appendChild(nm);
2175 if (sender.kind === "ai" || sender.kind === "bot") {
2176 const badge = document.createElement("span");
2177 badge.className = "local-notice-badge";
2178 badge.textContent = sender.kind === "ai" ? "AI" : "BOT";
2179 head.appendChild(badge);
2180 }
2181 }
2182 // In a solo thread privacy is implicit — everything here is only yours.
2183 if (!opts.solo) {
2184 const tag = document.createElement("span");
2185 tag.className = "local-notice-tag";
2186 tag.textContent = "Only visible to you";
2187 head.appendChild(tag);
2188 }
2189 if (head.childElementCount) row.appendChild(head);
2190
2191 // A chips-only notice (a persisted bubble's live actions) has no bubble of
2192 // its own — the text it belongs to is already in the feed as real history.
2193 if (text || spec.img || spec.quote) {
2194 const bubble = document.createElement("div");
2195 bubble.className = "local-notice-bubble";
2196 const body = document.createElement("div");
2197 body.className = "local-notice-body";
2198 renderNoticeBody(text || "", body);
2199 bubble.appendChild(body);
2200 // Quoted machine output (a provider's error line): set off visually as a
2201 // quotation under the prose — never dumped into the body where it reads as
2202 // the notice's own words. Plain textContent: it's evidence, not content.
2203 if (spec.quote) {
2204 const q = document.createElement("div");
2205 q.className = "local-notice-quote";
2206 q.textContent = String(spec.quote).slice(0, 600);
2207 bubble.appendChild(q);
2208 }
2209 // The place footer: a routed command's reply names where the data landed
2210 // (stamped by runCommand — see ctx.reply there). The reply renders where
2211 // you TYPED; this line says the action happened somewhere else.
2212 if (spec.place && placePhrase(spec.place)) {
2213 const pl = document.createElement("div");
2214 pl.className = "local-notice-place";
2215 pl.textContent = placePhrase(spec.place);
2216 bubble.appendChild(pl);
2217 }
2218 // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL
2219 // (e.g. the setup conversation previewing /api/server-avatar) — never user text.
2220 if (spec.img) {
2221 const img = document.createElement("img");
2222 img.className = "local-notice-img" + (spec.imgRound ? " local-notice-img--round" : "");
2223 img.src = spec.img;
2224 img.alt = spec.imgAlt || "";
2225 // The image arrives after the notice was pinned and grows the feed —
2226 // re-pin, or everything after it (the next typing row!) hides below the fold.
2227 img.onload = () => {
2228 const feed = img.closest(".chatapp-feed");
2229 if (feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 260) feed.scrollTop = feed.scrollHeight;
2230 };
2231 bubble.appendChild(img);
2232 }
2233 row.appendChild(bubble);
2234 }
2235
2236 if (Array.isArray(actions) && actions.length) {
2237 if (!text && !spec.img) row.classList.add("local-notice--chips"); // hugs the bubble it belongs to
2238 const chipRow = document.createElement("div");
2239 chipRow.className = "chip-row";
2240 const paint = (btn, act) => {
2241 // A chip whose moment has passed (its step was answered or skipped —
2242 // a.stale() is the script's step guard) renders spent: disabled, and the
2243 // one that was actually tapped shows its past-tense label ("Skipped").
2244 const stale = !!(act.stale && act.stale());
2245 btn.className =
2246 "action-chip" +
2247 (stale ? " action-chip--spent" : "") +
2248 // The FIRST trailing chip carries the auto margin that pushes the
2249 // trailing run to the row's end; the rest just follow it.
2250 (act.trailingFirst ? " action-chip--trailing" : "");
2251 btn.textContent = stale && act.tapped ? act.done || act.label : act.label;
2252 btn.disabled = stale;
2253 };
2254 const buttons = [];
2255 let trailingSeen = false;
2256 for (const a of actions) {
2257 if (!a || !a.label) continue;
2258 if (a.trailing && !trailingSeen) {
2259 trailingSeen = true;
2260 a.trailingFirst = true;
2261 }
2262 const b = document.createElement("button");
2263 b.type = "button";
2264 paint(b, a);
2265 b.onclick = () => {
2266 if (a.stale && a.stale()) return; // raced a resolution — don't claim the tap did anything
2267 a.tapped = true;
2268 runNoticeAction(a);
2269 // An advancing tap resolves the step SYNCHRONOUSLY (go.taken flips in
2270 // run) — reflect it now, not at the next feed render.
2271 for (const [btn, act] of buttons) paint(btn, act);
2272 };
2273 buttons.push([b, a]);
2274 chipRow.appendChild(b);
2275 }
2276 row.appendChild(chipRow);
2277 }
2278}
2279
2280// The calmed-down one-liner: glyph · sender · what happened · action link(s).
2281function fillCompactNotice(row, spec = {}) {
2282 row.classList.add("local-notice--compact");
2283 const { sender, actions } = spec;
2284 const { from, what } = noticeParts(spec);
2285 if (sender && (sender.logo || sender.glyph)) {
2286 const g = document.createElement("span");
2287 g.className = "local-notice-glyph";
2288 appGlyphInto(g, sender);
2289 row.appendChild(g);
2290 }
2291 if (from) {
2292 const nm = document.createElement("span");
2293 nm.className = "lc-from";
2294 nm.textContent = from;
2295 row.appendChild(nm);
2296 }
2297 if (what) {
2298 const summary = document.createElement("span");
2299 summary.className = "lc-summary";
2300 summary.textContent = what;
2301 row.appendChild(summary);
2302 }
2303 // The calmed one-liner keeps the place too — it's the durable record of the
2304 // interaction, and where it acted is part of what happened.
2305 if (spec.place && placePhrase(spec.place)) {
2306 const pl = document.createElement("span");
2307 pl.className = "lc-place";
2308 pl.textContent = placePhrase(spec.place);
2309 row.appendChild(pl);
2310 }
2311 if (Array.isArray(actions)) {
2312 for (const a of actions) {
2313 if (!a || !a.label) continue;
2314 if (a.stale && a.stale()) continue; // spent chips add nothing to a compact one-liner
2315 const b = document.createElement("button");
2316 b.type = "button";
2317 b.className = "lc-action";
2318 b.textContent = a.label;
2319 b.onclick = () => runNoticeAction(a);
2320 row.appendChild(b);
2321 }
2322 }
2323}
2324
2325// Collapse the loud card down to the compact one-liner with a "blind up": swap in
2326// the compact content, then animate the row's HEIGHT from its full height down to
2327// the compact height so the feed below slides up smoothly instead of jerking.
2328// Reduced-motion (or a detached node — navigated away, the rebuilt node starts
2329// compact) just swaps instantly.
2330function collapseNotice(row, spec) {
2331 if (!row.isConnected) return;
2332 row.setAttribute("aria-live", "off"); // don't re-announce the collapsed copy
2333 const swap = () => {
2334 row.replaceChildren();
2335 fillCompactNotice(row, spec);
2336 };
2337 if (!motionOK()) {
2338 swap();
2339 return;
2340 }
2341 const startH = row.offsetHeight; // full height, before the swap
2342 swap();
2343 const endH = row.offsetHeight; // compact height, after the swap
2344 if (endH >= startH) return; // nothing to roll up
2345 row.style.overflow = "hidden";
2346 row.style.height = startH + "px";
2347 void row.offsetHeight; // force a reflow so the start height sticks
2348 row.style.transition = "height 0.28s ease";
2349 row.style.height = endH + "px";
2350 const cleanup = () => {
2351 row.style.removeProperty("height");
2352 row.style.removeProperty("overflow");
2353 row.style.removeProperty("transition");
2354 };
2355 // transitionend normally clears the inline styles; a timeout is the backstop
2356 // if it doesn't fire (e.g. the tab was hidden for the whole animation).
2357 row.addEventListener("transitionend", cleanup, { once: true });
2358 setTimeout(cleanup, 400);
2359}
2360
2361// buildLocalNotice renders a command's private "only visible to you" reply as a
2362// CLIENT-ONLY feed row — never sent to the server or other members, gone on
2363// reload (like Slack's ephemeral replies). It PERSISTS: no auto-dismiss, so it
2364// stacks in the feed like a chat bubble and multiple replies pile up in order.
2365// It's loud at first, then calms: a fresh reply shows the full card and, after
2366// NOTICE_COMPACT_AFTER_MS, blind-collapses to a compact one-line record; a reply
2367// already older than that (rebuilt when you reopen the thread) starts compact.
2368// The chat app appends the returned node into its feed (chat.js keys it as a row).
2369export function buildLocalNotice(spec = {}, opts = {}) {
2370 const row = document.createElement("div");
2371 row.className = "local-notice";
2372 // Notices behave like regular participants: a run of them from the same
2373 // speaker keeps one header (opts.cont drops it), and in a solo thread the
2374 // "Only visible to you" tag is implicit (opts.solo drops it).
2375 if (opts.cont) row.classList.add("local-notice--cont");
2376 row.setAttribute("role", "status"); // announced to screen readers on insert
2377 row.setAttribute("aria-live", "polite");
2378 const at = spec.at || 0;
2379 const now = Date.now();
2380 // A fresh notice eases up into the feed like a live message; rebuilds of
2381 // older ones (thread reopened, feed refresh) don't re-animate.
2382 if (at && now - at < 3000) row.classList.add("appear");
2383 // Only confirmations calm down; a content reply (/help) renders full and stays.
2384 const calms = noticeCalmsDown(spec);
2385 if (calms && noticeIsCompact(at, now)) {
2386 fillCompactNotice(row, spec);
2387 } else {
2388 fillFullNotice(row, spec, opts);
2389 // A confirmation still inside its loud window schedules its collapse for the
2390 // time remaining before it's due to calm down.
2391 const remaining = calms && at ? Math.max(0, NOTICE_COMPACT_AFTER_MS - (now - at)) : 0;
2392 if (remaining > 0 && typeof setTimeout !== "undefined") {
2393 setTimeout(() => collapseNotice(row, spec), remaining);
2394 }
2395 }
2396 return row;
2397}
2398
2399// ---- message actions ----------------------------------------------------
2400
2401// An app can contribute an action to the chat message menu (long-press on touch,
2402// hover/press-hold on desktop) via a `messageAction(msg)` hook. Given a decrypted
2403// message ({ text, from }), it returns { label, glyph, run(ctx) } to offer the
2404// action, or null to sit this message out. Like a slash command, the action is
2405// routed into the app's own personal app-thread — created on first use — so, e.g.,
2406// saving a link someone posted files it into YOUR bookmarks, encrypted, without
2407// the server ever learning what (or that anything) was saved.
2408//
2409// messageActionsFor returns the applicable actions across all apps, each already
2410// bound to resolve + emit into the right app-thread and toast on success. shell.js
2411// appends these to both message menus.
2412export function messageActionsFor(msg) {
2413 const out = [];
2414 for (const app of apps) {
2415 if (!app.messageAction) continue;
2416 let spec = null;
2417 try {
2418 spec = app.messageAction(msg);
2419 } catch {}
2420 if (!spec || !spec.run) continue;
2421 out.push({
2422 label: spec.label,
2423 glyph: spec.glyph,
2424 run: async () => {
2425 // Always the PERSONAL instance — even when the app is added to this
2426 // group or installed for the space. Saving somebody's message
2427 // privately is the point of the gesture (see bookmarks.js); the
2428 // shared instances are reached by command/mention, always explicit.
2429 const target = resolveAppThread ? await resolveAppThread(app.name) : null;
2430 if (!target) return;
2431 await spec.run(makeCtx(target, app));
2432 if (toastFn) toastFn(`Added to ${app.title || app.name}`);
2433 },
2434 });
2435 }
2436 return out;
2437}
2438
2439// ---- sidebar presence: previews + unread dots -----------------------------
2440
2441// An app decides how its events appear in the chat list: `preview(event)`
2442// returns the sidebar preview line (or null to leave the existing preview),
2443// and `unread(event)` says whether a live event should light the thread's
2444// unread dot. Defaults preserve the old behaviour — app events are silent in
2445// the sidebar — so an app opts IN to presence (e.g. poll shows "📊 <question>"
2446// and marks unread when someone starts a poll). This removes the host's
2447// hardcoded "app events never preview/unread" special case: the app decides.
2448export function appPreview(event, opts) {
2449 const app = typeOwner.get(event && event.type);
2450 if (!app) return null;
2451 const you = !!(opts && opts.you);
2452 if (app.preview) {
2453 try {
2454 return app.preview(event, { you }) || null;
2455 } catch {
2456 return null;
2457 }
2458 }
2459 // The voice defaulting chain (docs/native-apps.md): no preview hook → the
2460 // chat projection speaks for the sidebar too, actor-prefixed the way the
2461 // feed prefixes action lines. Silent projections stay silent.
2462 try {
2463 let line = null;
2464 if (typeof app.groupFeedMessage === "function") line = app.groupFeedMessage(event);
2465 else if (typeof app.chatMessage === "function") line = app.chatMessage(event, { group: true });
2466 if (!line) return null;
2467 if (typeof line === "string") return line;
2468 if (typeof line.text !== "string" || !line.text) return null;
2469 if (!line.action) return line.text;
2470 return `${you ? "You" : event.name || "Someone"} ${line.text}`;
2471 } catch {
2472 return null;
2473 }
2474}
2475
2476export function appUnread(event) {
2477 const app = typeOwner.get(event && event.type);
2478 if (!app || !app.unread) return false;
2479 try {
2480 return !!app.unread(event);
2481 } catch {
2482 return false;
2483 }
2484}
2485
2486// ---- spontaneous app → user communication --------------------------------
2487
2488// notifyLive gives an app a voice when one of its events arrives LIVE (over the
2489// WebSocket — including a scheduled event the server just delivered), no matter
2490// which thread the user is looking at. Two ways to speak:
2491// - the event carries a `notice: { title, body }` (encrypted like the rest of
2492// the payload) — the ONE field that alerts everywhere: the service worker
2493// shows it when the app is closed, this shows it when the app is open;
2494// - or the app defines a `notify(event, ctx)` hook returning { title, body }
2495// (or null for silence) — for wording that depends on current state.
2496// An app event that arrives while the page is HIDDEN raises a system
2497// notification (permission permitting) — the background equivalent of the push
2498// a closed app gets. There is deliberately no in-app floating toast: a live app
2499// event announces itself where it belongs — the event lands in its thread, which
2500// floats to the top of the chat list (sorted by recency) with an unread dot and
2501// preview. A pop-up that overlays whatever you're reading is exactly the thing
2502// CLAUDE.md bans. Never called during history refolds: replaying the past must not re-alert.
2503export function notifyLive(threadId, event) {
2504 const app = typeOwner.get(event && event.type);
2505 if (!app) return;
2506 let spec = null;
2507 if (app.notify) {
2508 try {
2509 spec = app.notify(event, makeCtx(threadId, app));
2510 } catch {}
2511 } else if (event.notice && event.notice.title) {
2512 spec = event.notice;
2513 }
2514 if (!spec || !spec.title) return;
2515 try {
2516 if (
2517 typeof Notification !== "undefined" &&
2518 Notification.permission === "granted" &&
2519 typeof document !== "undefined" &&
2520 document.visibilityState !== "visible"
2521 ) {
2522 new Notification(spec.title, { body: spec.body || "", tag: "lchat-app-" + app.name });
2523 }
2524 } catch {}
2525}
2526
2527// ---- surfaces -----------------------------------------------------------
2528
2529// renderInline asks the owning app for a stream line (a DOM node) representing an
2530// event, or null if the event should be silent in the feed. Used by appendMessage
2531// to give app events an optional presence in the message stream.
2532export function renderInline(app, event, meta) {
2533 if (!app || !app.renderEvent) return null;
2534 return app.renderEvent(event, meta || {});
2535}
2536
2537// The legacy ambient-panel renderer (scope:"shared") is RETIRED — #405 closed:
2538// Poll and Whiteboard are chat-place apps in the widget bar now, and no app
2539// renders into a panel above the feed. A legacy custom app still declaring
2540// scope:"shared" degrades to command-only (its commands run; nothing mounts).
2541
2542// renderApp draws a single (personal) app full-pane into `el` — the whole thread
2543// view when you open the app from the sidebar. State is the folded event log.
2544export function renderApp(threadId, appName, el) {
2545 const app = appByName(appName);
2546 if (!app || !app.render || !el) return;
2547 runCleanups(threadId, app.name);
2548 // A member-app mount tags the pane .sandbox-pane (sandbox.js), which now
2549 // strips the pane's padding; the tag must not survive onto a built-in app
2550 // reusing the same pane. The sandbox render re-adds it every time.
2551 if (!app.memberApp) el.classList.remove("sandbox-pane");
2552 if (!app.incremental) el.textContent = "";
2553 // A mini-app's render must NEVER take down the host. This surface can be the
2554 // whole thread, and it's reached on boot (reopening your last thread) — an
2555 // unguarded throw here white-screens the entire client ("Something went
2556 // wrong"). Contain it: log, and show a localized, non-fatal message in the
2557 // app's own pane so the rest of the app still loads.
2558 try {
2559 app.render(appStateFor(threadId, app.name), el, makeCtx(threadId, app));
2560 } catch (e) {
2561 console.error(`app "${app.name}" render failed:`, e);
2562 el.textContent = "";
2563 const p = document.createElement("p");
2564 p.style.cssText = "padding:16px;color:var(--muted);font-size:13px;line-height:1.5";
2565 p.textContent = `This view hit an error and couldn't load (${(e && e.message) || e}). Try reopening it.`;
2566 el.appendChild(p);
2567 }
2568}
2569