web: app-authoring archetypes + begin dissolving scope into flags (#405 step 1) (#425)
Building the vault app surfaced that the mini-app authoring guide steers you wrong: "a group tool → scope: shared" reads right but gives you the ambient panel, when a group tool is really a personal + groupCapable app. This is the #405 confusion at the docs level, so fix both together — the docs, and #405 step 1 (behavior-neutral). apps.js — introduce orthogonal capability flags on defineApp: - personal: may have a personal self-thread instance (defaults true) - listed: user-instantiable vs host-mounted only (defaults true; the old scope:"system") - groupCapable already existed (may be added to a group) normalizeScope() derives the flags from a legacy scope and derives a canonical scope back from flags, so old declarations and new flag-first ones agree and app files can migrate one at a time. scope stays populated, so every existing .scope check is untouched; the personal-instance reads switch to the flag. renderSurfaces now also excludes groupCapable apps from the ambient panel (behavior-neutral today — no shared app is groupCapable — but makes a future group-only app safe). scope is deprecated, not removed. README — a new "Pick your app's shape first" section: the three independent choices (personal? groupCapable? chatMessage?), an archetype table, the "'shared' is not 'shareable'" trap called out explicitly, and a layout-patterns list pointing at the example app for each. Fields table + scope section rewritten around the flags; the canonical example declares personal: true. Explicitly NOT here (later #405 steps, each its own PR): migrating the built-in apps' declarations, moving Whiteboard to the widget bar, deciding Poll's fate, and deleting the ambient-panel path. Test: defineApp derivation is covered both directions in test/apps.test.mjs; full npm test suite green.
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+192
−29
test/apps.test.mjs+25 −0web/static/apps.js+65 −10web/static/apps/README.md+102 −19
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 31426db..b01e64d 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -366,6 +366,31 @@ await test("appWantsChatView: chatMessage opts into companion; its absence is ta |
| assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion |
| }); |
| +await test("defineApp reconciles the legacy scope enum with the personal/listed flags (#405)", () => { |
| + const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec }); |
| + // Legacy scope declarations derive concrete flags… |
| + const p = mk({ name: "flag-legacy-personal", scope: "personal" }); |
| + assert.deepEqual([p.personal, p.listed], [true, true]); |
| + const sh = mk({ name: "flag-legacy-shared", scope: "shared" }); |
| + assert.deepEqual([sh.personal, sh.listed], [false, true]); |
| + const sy = mk({ name: "flag-legacy-system", scope: "system" }); |
| + assert.deepEqual([sy.personal, sy.listed], [false, false]); |
| + // …the default archetype (no scope, no flags) is a personal, listed app… |
| + const def = mk({ name: "flag-default" }); |
| + assert.deepEqual([def.scope, def.personal, def.listed], ["personal", true, true]); |
| + // …and a flag-first declaration derives a canonical scope back, with the |
| + // complementary flag filled in, so every legacy `.scope` check still works. |
| + const both = mk({ name: "flag-first-both", personal: true, groupCapable: true }); |
| + assert.deepEqual([both.scope, both.personal, both.listed], ["personal", true, true]); |
| + const groupOnly = mk({ name: "flag-first-grouponly", personal: false, groupCapable: true }); |
| + assert.deepEqual([groupOnly.scope, groupOnly.personal, groupOnly.listed], ["shared", false, true]); |
| + const hostOnly = mk({ name: "flag-first-hostonly", listed: false }); |
| + assert.deepEqual([hostOnly.scope, hostOnly.personal, hostOnly.listed], ["system", false, false]); |
| + // The real apps keep their classification (behaviour-neutral migration). |
| + assert.deepEqual([todoApp.personal, todoApp.listed, !!todoApp.groupCapable], [true, true, true]); |
| + assert.deepEqual([pollApp.personal, pollApp.listed], [false, true]); |
| +}); |
| + |
| // ---- collection helpers (the immutable list ops reducers build on) ------- |
| await test("addItem appends, dedupes by id, and never mutates the input", () => { |
diff --git a/web/static/apps.js b/web/static/apps.js| index 39a8ffb..8218f51 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -84,10 +84,19 @@ injectStyles( |
| // defineApp registers a mini-app. Called at module load by each app file. Fields: |
| // name, title, glyph — identity (title/glyph shown in the sidebar + header) |
| -// scope: "personal" (its own single-member app-thread, rendered full-pane) | |
| -// "shared" (runs in the current chat, rendered as a panel above the feed) | |
| -// "system" (mounted explicitly by the host, e.g. the chat feed — |
| -// never listed in the Apps menu, never given a shared panel) |
| +// personal: true — the app may have a PERSONAL instance: its own single-member |
| +// app-thread, opened from the Apps menu and rendered full-pane. Drives the |
| +// sidebar/Apps-menu machinery, personal-command routing, and defaultCommand. |
| +// groupCapable: true — the app may ALSO be added to a group chat, where one shared |
| +// instance runs for everyone (see "group-scoped apps" below). Orthogonal to |
| +// `personal`: an app can be personal-only, both, or (later) group-only. |
| +// listed: false — NOT user-instantiable; host-mounted only (the chat feed, the |
| +// setup/welcome conversations, the groupapps registry). Defaults to true. |
| +// scope — DEPRECATED, kept during the #405 migration. `defineApp` derives the |
| +// flags above from it (and vice-versa) so old and new declarations agree: |
| +// "personal" → { personal:true, listed:true }; "system" → { listed:false }; |
| +// "shared" → the legacy ambient panel above the feed (Poll) — the one thing |
| +// scope still uniquely encodes, until that path is retired (#405). |
| // events — the fold, as { "<type>": (state, event) => state } |
| // initialState — () => the empty state (seeded on the first event) |
| // commands — slash commands the app owns, as |
| @@ -114,7 +123,10 @@ injectStyles( |
| // The owned event types (app.types) and the reducer (app.reduce) are derived from |
| // `events` — one source of truth, so a type can never drift out of sync. |
| export function defineApp(spec) { |
| - const app = { scope: "personal", commands: {}, ...spec }; |
| + const app = { commands: {}, ...spec }; |
| + // Reconcile the legacy `scope` enum with the capability flags it's dissolving |
| + // into (#405), so both old scope-checks and new flag-reads see a consistent app. |
| + normalizeScope(app); |
| // Normalise each command to { run, desc, args } so a bare handler function and |
| // the richer {run,desc,args} form both work — desc/args feed the "/" typeahead. |
| app.commands = normalizeCommands(app.commands); |
| @@ -146,6 +158,41 @@ function normalizeCommands(cmds) { |
| return out; |
| } |
| +// normalizeScope reconciles the legacy `scope` enum with the orthogonal capability |
| +// flags it's being dissolved into (issue #405). `scope` always conflated three |
| +// questions; the flags split out the two that are really about the app: |
| +// - `personal`: can it have a personal, self-thread instance? (Apps menu, |
| +// personal-command routing, defaultCommand.) `groupCapable` — may it ALSO be |
| +// added to a group — is already its own flag, so "shared into a group" and |
| +// "has a personal thread" are independent, not two ends of one axis. |
| +// - `listed`: is it user-instantiable, or host-mounted only? (`scope:"system"` |
| +// really only ever meant `listed:false`.) |
| +// During the migration an app may declare EITHER `scope` (legacy) or the flags; |
| +// whichever it gives, we derive the rest so every `.scope` check and every flag |
| +// read agree. The one thing `scope` still uniquely encodes — "shared" = the ambient |
| +// panel above the feed (Poll) — stays keyed on scope until that path is retired. |
| +function normalizeScope(app) { |
| + const declaredFlags = app.personal !== undefined || app.listed !== undefined; |
| + if (app.scope === undefined && !declaredFlags) app.scope = "personal"; // the default archetype |
| + if (app.scope !== undefined) { |
| + // Legacy declaration → derive any flag not explicitly overridden (an explicit |
| + // flag still wins, so an app can migrate one field at a time). |
| + if (app.personal === undefined) app.personal = app.scope === "personal"; |
| + if (app.listed === undefined) app.listed = app.scope !== "system"; |
| + } else { |
| + // Flag-first declaration → fill in the complementary flags (so both are always |
| + // concrete booleans), then derive a canonical `scope` so every legacy check |
| + // keeps working: personal → "personal"; not-listed → "system"; anything else |
| + // (a group-only or panel app) → "shared". Note "shared" is overloaded here: |
| + // it means "ambient panel" for a legacy panel app but just "not personal" for a |
| + // group-only widget-bar app — renderSurfaces disambiguates on `groupCapable`, so |
| + // a group-only app never draws a panel. That overload is exactly what #405 ends. |
| + if (app.personal === undefined) app.personal = false; |
| + if (app.listed === undefined) app.listed = true; |
| + app.scope = app.personal ? "personal" : !app.listed ? "system" : "shared"; |
| + } |
| +} |
| + |
| // makeReducer turns an events map + initialState factory into a fold function |
| // (state, event) → state. It seeds the empty state on the first event, dispatches |
| // each event to its handler by type, and returns state UNCHANGED for any type the |
| @@ -267,7 +314,7 @@ export function appByName(name) { |
| return apps.find((a) => a.name === name) || null; |
| } |
| export function personalApps() { |
| - return apps.filter((a) => a.scope === "personal"); |
| + return apps.filter((a) => a.personal); |
| } |
| // appWantsChatView decides how a personal app-thread presents. An app that |
| @@ -1060,7 +1107,7 @@ export function runCommand(text, activeThreadId, respond) { |
| // and the action line). A bare "/todo @here" is just the install. Only |
| // meaningful for a group-capable personal app in a group — anywhere else |
| // it answers with a private explanation rather than mis-filing the text. |
| - const here = !cmd.local && app.scope === "personal" && parseHere(args); |
| + const here = !cmd.local && app.personal && parseHere(args); |
| if (here) { |
| args = here.rest; |
| if (!app.groupCapable) { |
| @@ -1089,7 +1136,7 @@ export function runCommand(text, activeThreadId, respond) { |
| // like "@To-do". Your personal list stays reachable from its own thread |
| // (and from any chat without the app). |
| const addedHere = groupAppsFor(activeThreadId).some((a) => a.name === app.name); |
| - if (!cmd.local && app.scope === "personal" && !addedHere) { |
| + if (!cmd.local && app.personal && !addedHere) { |
| target = resolveAppThread ? await resolveAppThread(app.name) : null; |
| } |
| } |
| @@ -1144,7 +1191,7 @@ export function commandCatalog(threadId) { |
| // In a group, say WHERE a group-capable app's command will land: the chat's |
| // shared instance if the app is added here, else your personal one — with |
| // the "@here" flag as the discoverable way to share it (see runCommand). |
| - if (desc && app.scope === "personal" && app.groupCapable && threadId !== undefined && threadIsGroupFn && threadIsGroupFn(threadId)) { |
| + if (desc && app.personal && app.groupCapable && threadId !== undefined && threadIsGroupFn && threadIsGroupFn(threadId)) { |
| desc += groupAppsFor(threadId).some((a) => a.name === app.name) ? " · shared in this chat" : " · yours — @here to share"; |
| } |
| out.push({ |
| @@ -1571,10 +1618,18 @@ export function renderInline(app, event, meta) { |
| // renderSurfaces (re)draws every SHARED app for a thread into `mount` (the |
| // #app-surface panel above a chat feed). Each app gets its own container; an app |
| // that has no state renders nothing, so the container (and surface) collapse. |
| +// This is the LEGACY ambient-panel path — the one thing `scope: "shared"` still |
| +// uniquely selects — so the gate stays keyed on `scope`, not a flag, until the |
| +// panel is retired by migrating its tenants to the widget bar / interactive |
| +// messages (#405). Don't "modernise" this to a capability flag in isolation. |
| export function renderSurfaces(threadId, mount) { |
| if (!mount) return; |
| for (const app of apps) { |
| - if (app.scope !== "shared" || !app.render) continue; |
| + // The ambient panel is only for legacy `shared` apps (Poll). A group-capable |
| + // app renders through the widget bar, never here — so it's excluded even if it |
| + // derived scope "shared" (a group-only app). Behaviour-neutral today: no real |
| + // `shared` app is groupCapable. |
| + if (app.scope !== "shared" || app.groupCapable || !app.render) continue; |
| let el = mount.querySelector(`.app-panel[data-app="${app.name}"]`); |
| if (!el) { |
| el = document.createElement("section"); |
diff --git a/web/static/apps/README.md b/web/static/apps/README.md| index 89f8eea..0caacc1 100644 |
| --- a/web/static/apps/README.md |
| +++ b/web/static/apps/README.md |
| @@ -53,7 +53,7 @@ export const app = defineApp({ |
| name: "note", // unique id, lowercase — also the event-type prefix |
| title: "Notes", // shown in the sidebar and header |
| glyph: "📝", // avatar glyph |
| - scope: "personal", // its own sidebar thread, rendered full-pane |
| + personal: true, // its own sidebar thread, rendered full-pane (also the default) |
| // The empty state. A factory (not a value) so every thread gets its own. |
| initialState: () => ({ items: [] }), |
| @@ -85,7 +85,7 @@ export const app = defineApp({ |
| .note-text { flex: 1 1 auto; overflow-wrap: anywhere; } |
| `, |
| - // Personal scope: `el` is the whole thread pane. Build DOM nodes and set |
| + // Personal, full-pane: `el` is the whole thread pane. Build DOM nodes and set |
| // text with textContent — NEVER innerHTML for anything a user typed. |
| render(state, el, ctx) { |
| for (const item of state.items) { |
| @@ -112,6 +112,72 @@ import "/static/apps/note.js"; |
| That's a whole app. Everything below is reference detail and the optional hooks. |
| +## Pick your app's shape first |
| + |
| +Before the field reference, decide **which archetype you're building** — most |
| +wrong turns happen here, by reaching for a scope that sounds right ("a group |
| +tool → shared") instead of the one that matches the *shape* you want. An app's |
| +shape is three independent choices, not one enum: |
| + |
| +1. **Does it have a personal instance?** (`personal: true`) — its own single-member |
| + thread in the sidebar, opened on demand, rendered full-pane. This is the To-do |
| + experience. |
| +2. **Can a whole group share one instance?** (`groupCapable: true`) — it can also |
| + be **added to a group chat** (⋮ → Add apps), where one instance folds the group |
| + thread's log so every member sees the same state. Orthogonal to #1: compose them. |
| +3. **Does it project into chat?** (a `chatMessage` hook) — with it, the app is a |
| + **companion** (a Chat view beside its Interface); without it, a **takeover** |
| + (its `render` IS the whole surface). See "Views" in CLAUDE.md. |
| + |
| +Those compose into the archetypes you'll actually build: |
| + |
| +| You want… | declare | surface | examples | |
| +| --------- | ------- | ------- | -------- | |
| +| A private tool that IS its own thread | `personal: true` (no `chatMessage`) | full-pane takeover | `board.js` (whiteboard), a secrets vault | |
| +| A private tool you also chat with | `personal: true` + `chatMessage` | full-pane companion (Chat ⇄ Interface) | `todo`, `remind` | |
| +| …either of those, shareable to a group | add **`groupCapable: true`** | + added to a group via ⋮ Add apps, drawn in the group's Interface pane | `todo`, `remind`, `bookmarks` | |
| +| An interactive widget that lives *in* the conversation | `scope: "shared"` (legacy) | an ambient panel above the feed | `poll` | |
| +| Not user-instantiable (host mounts it) | `listed: false` | host decides | the chat feed, `setup` | |
| + |
| +The trap to avoid: **"shared" is not "shareable."** `scope: "shared"` is the |
| +*ambient panel* model (Poll) — always present in every chat, no install. A tool a |
| +group *adds and shares* is a `personal` + `groupCapable` app (To-do), which is the |
| +most shared thing in the system and is **not** `scope: "shared"`. When in doubt, |
| +start from `personal` and add `groupCapable`; reach for `shared` only when you |
| +specifically want the always-on panel. (These flags are dissolving the `scope` |
| +enum entirely — see "Instance & discovery flags" below and issue #405.) |
| + |
| +### Layout patterns — and the example app that shows each |
| + |
| +Once you've picked a shape, copy the closest working app rather than starting cold: |
| + |
| +- **Full-pane list** (`todo.js`, `remind.js`) — an add form on top, |
| + then the list, optionally a collapsed archive/history section. Rebuilt from state |
| + on every change (NOT `incremental`); it's the simplest correct thing, and it's |
| + what makes a group instance converge — another member's edit just refolds and |
| + redraws. Keep transient UI (a reveal toggle, a grace-period ring) on the row DOM, |
| + never in reducer state. |
| +- **Ambient panel** (`poll.js`) — **render nothing when empty** so the panel |
| + collapses (invariant #6), and set `incremental: true` + use `keyedList` so a |
| + half-interacted card (an armed "Close?" button) survives the refolds that votes |
| + trigger. Handlers read `card._ctx` (refreshed each render), because the one |
| + `#app-surface` is reused across threads — a stashed thread id would target the |
| + wrong chat. |
| +- **Long / scroll-back lists** (`chat.js`, `board.js`) — `incremental: true` + |
| + `keyedList` with a `rev`, so unchanged rows keep their exact nodes (scroll |
| + position, focus, decrypted images survive). |
| +- **Companion projection** — implement `chatMessage(event, { group })`: return a |
| + bot bubble (a string) or a `/me`-style action line (`{ text, action: true }`), |
| + or `null` for silence. The `{ group }` hint lets wording differ when many people |
| + read it (`remind.js` drops "you asked me" in a group). |
| +- **Two-step inline confirm** (`poll.js`) — embedded webviews have no |
| + `confirm()` panel (it silently answers false), so never call it: a first click |
| + arms the button ("Remove?"), a second within a few seconds commits. |
| + |
| +A shape decision worth making deliberately: **a takeover secrets/notes tool should |
| +usually NOT implement `chatMessage`**, so its events don't spill names into the |
| +chat feed, previews, and push notifications (a secrets vault is best built this way). |
| + |
| ## `defineApp(spec)` — the fields |
| | field | required | what it is | |
| @@ -119,8 +185,10 @@ That's a whole app. Everything below is reference detail and the optional hooks. |
| | `name` | ✓ | Unique lowercase id. Also the conventional prefix for your event types. | |
| | `title` | ✓ | Human name (sidebar, header). | |
| | `glyph` | ✓ | One emoji/character used as the app's avatar. | |
| -| `scope` | ✓ | `"personal"` or `"shared"` — see below. | |
| -| `groupCapable` | | A personal app that can ALSO be added to a group chat (⋮ → Add apps), where one shared instance runs for everyone. See "Group-scoped apps". | |
| +| `personal` | | `true` if the app may have a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`). Defaults to `true`. | |
| +| `groupCapable` | | `true` if the app can ALSO be added to a group chat (⋮ → Add apps), where one shared instance runs for everyone. Orthogonal to `personal`. See "Group-scoped apps". | |
| +| `listed` | | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `true`. | |
| +| `scope` | | **Deprecated** — being dissolved into the flags above (#405). Still accepted: `"personal"`/`"shared"`/`"system"` derive the flags, and vice-versa. See "Instance & discovery flags". | |
| | `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked — how a service "installs" its companion app. See `docs/connected-services.md`. | |
| | `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. | |
| | `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. | |
| @@ -140,26 +208,41 @@ That's a whole app. Everything below is reference detail and the optional hooks. |
| `defineApp` returns the app object (with `.types` and `.reduce` derived from |
| `events` — handy in tests). |
| -### `scope`: personal, shared, system |
| - |
| -- **`personal`** — the app is *yours*. It gets its own single-member thread in |
| - the sidebar and renders **full-pane** (`el` is the whole thread view). A |
| - command or message action typed in *any* chat routes into your app-thread, no |
| - matter where you triggered it. Use for private tools: to-do, reminders, |
| - bookmarks. (Examples: `todo.js`, `remind.js`, `bookmarks.js`.) |
| -- **`shared`** — the app runs **in the current chat**, visible to everyone in it, |
| - and renders as a **panel above the feed**. A command targets the current chat. |
| - Use for group tools: polls. (Example: `poll.js`.) |
| -- **`system`** — mounted explicitly by the host, never listed in the Apps menu |
| - and never given a shared panel. This is how chat-as-an-app is delivered |
| - (example: `shell.js`, the chat feed itself); you almost certainly want the other two. |
| +### Instance & discovery flags (`personal`, `groupCapable`, `listed`) |
| + |
| +`scope` used to be a single required enum, but it quietly conflated three |
| +different questions — where events go, what surface renders, and how the app is |
| +instantiated — and group-scoped apps pulled them apart (#405). So the model is |
| +now three orthogonal flags; `scope` is derived from them (and still accepted, so |
| +old app files keep working), but new apps should declare the flags directly. |
| + |
| +- **`personal: true`** (the default) — the app can have a **personal instance**: |
| + its own single-member thread in the sidebar, rendered **full-pane** (`el` is the |
| + whole thread view). A command or message action typed in *any* chat routes into |
| + your app-thread. Private tools live here: to-do, reminders, bookmarks, vault. |
| +- **`groupCapable: true`** — it can *also* be **added to a group chat**, where one |
| + shared instance runs for everyone (next section). This is what a group tool |
| + actually is: not a separate scope, but a `personal` app that opts into sharing. |
| +- **`listed: false`** — the app is **not user-instantiable**; the host mounts it |
| + explicitly (the chat feed, the `setup`/`welcome` conversations, the `groupapps` |
| + registry). Never appears in the Apps menu. (This is the old `scope: "system"`.) |
| +- **`scope: "shared"`** — the one archetype still carried by the enum: the |
| + **legacy ambient panel** above the feed (Poll). It's the "always present in |
| + every chat, no install" widget model, distinct from `groupCapable` sharing, and |
| + it (and `renderSurfaces`) is on the way out — see #405. Don't reach for it unless |
| + you specifically want that panel. |
| + |
| +How the legacy enum maps: `personal` → `{ personal: true, listed: true }`; |
| +`system` → `{ listed: false }`; `shared` stays the ambient panel. `defineApp` |
| +fills in whichever you didn't give, so both `.scope` checks and flag reads agree |
| +during the migration. |
| Not every full-pane thread surface should be an app, though — a pending (not-yet- |
| joined) DM shows a waiting card instead of the chat app's pane, but that's a |
| -host-level concern, not a fourth scope. See `docs/mini-apps-architecture.html` |
| +host-level concern, not an app archetype. See `docs/mini-apps-architecture.html` |
| §5 for why, and the design question to revisit once the classic feed is retired. |
| -### Group-scoped apps: the same app, personal AND shared |
| +### Group-scoped apps: the same app, personal AND in a group |
| A personal app marked **`groupCapable: true`** (To-do) can also be **added to a |
| group chat** (⋮ → Add apps), where one shared instance runs for the whole |