eleven / ocho Public

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>
James Adam c9cf3c1b1bea9db22a9eed2a3d8e9951d24c043d parent 14ce7de
3 files changed, +192 −29
  • test/apps.test.mjs +25 −0
  • web/static/apps.js +65 −10
  • web/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