web+docs: quiet apps — commands answer in place without forcing their thread into view (#227) (#629)
* web+docs: quiet apps — commands answer in place without forcing their thread into view (#227) A personal app's command has always resolved (and created, on first use) its own app-thread before the handler runs — necessary, since ctx.emit needs somewhere to land. But it also unconditionally surfaced that thread in the sidebar, and any later activity auto-reopened it even after the user closed it, because the server clears hidden_threads on every message insert and never learns which app owns a thread (by design — see CLAUDE.md). Add `quiet: true` (defineApp, and the member-app manifest schema) so an app can opt its personal thread out of that auto-surfacing: brand new stays hidden, already-visible is unaffected, and closed stays closed through further activity — the client reasserts the hide the server just cleared, since only the client can tell which app a thread belongs to. Nothing is ever lost: reopening later (the user's own tap) shows every event sent while hidden. Ship apps/dice.js (`/roll`) as the quiet-app reference implementation, alongside emoji.js/gif.js's command-panel pattern. Verified end-to-end against a live server + browser (provision → /roll → sidebar checks → close → /roll again → reopen), plus a regression check that a non-quiet app (To-do) keeps its old create-and-reopen behavior unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * web+test: park the dice quiet-app POC in disabled-apps, unregister it It was a proof-of-concept for verifying quiet:true (#227) live, not a feature to ship. Move it out of web/static/apps/ (so shell.js no longer loads it) into web/static/disabled-apps/, with a header explaining what it demonstrates for anyone who finds it later. Its unit tests keep exercising the reducer/command logic directly (no shell.js registration needed for that). Co-Authored-By: Claude <noreply@anthropic.com> * test: prove quiet works uniformly for groupCapable and member-supplied apps Explicit coverage for the two combinations that weren't yet pinned: - A personal+groupCapable+quiet app: personal routing (not added to the current chat) still resolves/creates the app-thread exactly as any other quiet app — quiet never changes whether it resolves, only its visibility. Added to a group, the command targets the group's shared instance directly and never touches the personal-thread resolver at all — structurally, the create/hide logic that makes an app "quiet" only lives inside createAppThread, which the group-shared path never calls. So quiet is a no-op there, by construction, not by a special case. - Member-app manifest: quiet:true round-trips through registerMemberApp (validateManifest, appIsQuiet) and through manifestOf/evalMemberApp (the App Studio authoring path) exactly like groupCapable already does — same mechanism, same enforcement, regardless of whether an app is built-in or user-supplied. Co-Authored-By: Claude <noreply@anthropic.com> ---------
Co-authored-by: Claude <noreply@anthropic.com>
9 files changed,
+369
−2
docs/building-member-apps.md+1 −0test/apps.test.mjs+132 −0test/sandbox.test.mjs+49 −0web/static/apps.js+20 −0web/static/apps/README.md+39 −0web/static/disabled-apps/dice.js+89 −0web/static/sandbox-frame.js+3 −0web/static/sandbox.js+8 −0web/static/shell.js+28 −2
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md| index a050184..15bc453 100644 |
| --- a/docs/building-member-apps.md |
| +++ b/docs/building-member-apps.md |
| @@ -122,6 +122,7 @@ export const app = defineApp({ /* fields below */ }); |
| | `defaultCommand` | | The name of one of your `commands` (e.g. `"/add"`). In the companion Chat view, plain (non-slash) text runs it — so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. | |
| | `chatMessage` | | `(event) => { text, action? } \| string \| null`. Opting in makes the app a **companion** (Chat + Interface views); see Companion chat. | |
| | `groupCapable` | | `true` to let members ALSO add this app to a group chat, one shared instance for everyone; see Group-capable apps. | |
| +| `quiet` | | `true` if a command answering in place (a reply, a panel, or an event it emits) must never itself force your app's personal thread into view — it still exists and still receives every event, it just won't pop into the sidebar on its own. The host already adds an "Open `<title>`" chip to every command reply, so the member can still jump there with their own tap. | |
| | `styles` | | A CSS string, injected once. Use the theme variables below. | |
| | `incremental` | | `true` to keep the surface between renders and reconcile with `keyedList`. | |
| | `ephemeral` | | `(event, ctx) => void`. Relay-only events (live presence), rarely needed. | |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 1e73bd7..5e5448e 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -54,6 +54,7 @@ import { |
| setCommandPanelHost, |
| openCommandPanel, |
| closeCommandPanel, |
| + appIsQuiet, |
| } from "../web/static/apps.js"; |
| import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| @@ -68,6 +69,7 @@ import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat. |
| import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| +import { app as diceApp, parseSides } from "../web/static/disabled-apps/dice.js"; // DISABLED quiet-app POC (issue #227) — not wired into shell.js |
| // Each app's fold is synthesised from its `events` map by defineApp — the same |
| // (state, event) → state reducer the framework folds with. Test it directly. |
| @@ -178,6 +180,38 @@ await test("an empty /todo emits nothing", async () => { |
| assert.equal(sent.length, 0); |
| }); |
| +// AUDIT (quiet apps, issue #227): runCommand resolves — and so creates — a |
| +// personal app's own thread BEFORE the handler runs, no matter what the |
| +// handler ends up doing. A reply-only turn (no ctx.emit at all) still pays |
| +// for a thread. That's BY DESIGN (the thread is where any future ctx.emit |
| +// from this app would land, so it has to exist) — `quiet` never changes this |
| +// resolution. What `quiet` changes is entirely about the thread's VISIBILITY |
| +// afterward (see shell.js's createAppThread + the "msg" WS handler, and |
| +// apps/README.md § Quiet apps) — this test just pins the resolution itself so |
| +// that distinction stays deliberate, not accidental. |
| +await test("a reply-only personal command still resolves (creates) the app-thread — quiet only hides it", async () => { |
| + let resolves = 0; |
| + setAppThreadResolver(async () => { |
| + resolves++; |
| + return "remindThread"; |
| + }); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| + const replies = []; |
| + // Unparseable time → remind.js only ctx.reply()s a clarifying question; it |
| + // never calls addReminder/ctx.emit. Typed from an unrelated chat. |
| + assert.equal(runCommand("/remind call mum", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| + assert.equal(sent.length, 0, "the handler never emitted anything"); |
| + assert.equal(replies.length, 1, "it only answered in place"); |
| + // resolveAppThread ran regardless — the thread exists whether or not this |
| + // particular turn emitted anything. Whether it's SHOWN is a separate |
| + // decision, made in shell.js (createAppThread hides it on creation for a |
| + // quiet app; the WS handler keeps it hidden through later activity too). |
| + assert.equal(resolves, 1); |
| +}); |
| + |
| await test("/help is answered privately (client-side); other bot commands pass through", () => { |
| const replies = []; |
| const respond = (r) => replies.push(r); |
| @@ -1852,5 +1886,103 @@ await test("a no-match /emoji answers with a reply, and the app stays command-on |
| setCommandPanelHost(null); |
| }); |
| +// ---- quiet apps (issue #227) --------------------------------------------- |
| + |
| +await test("appIsQuiet reads the flag off the app registry", () => { |
| + assert.equal(appIsQuiet("dice"), true); |
| + assert.equal(appIsQuiet("todo"), false, "an ordinary personal app is not quiet"); |
| + assert.equal(appIsQuiet("nope-not-a-real-app"), false); |
| + assert.equal(appIsQuiet(undefined), false, "no app name (e.g. a non-app thread) → not quiet"); |
| +}); |
| + |
| +// quiet and groupCapable are orthogonal (like personal/groupCapable already |
| +// are) — quiet only ever governs a PERSONAL thread's visibility. Prove both |
| +// halves of that claim against a single app that's both. |
| +defineApp({ |
| + name: "questpin", |
| + title: "Quest Pin", |
| + glyph: "📌", |
| + personal: true, |
| + groupCapable: true, |
| + quiet: true, |
| + initialState: () => ({}), |
| + events: { "questpin.set": (s) => s }, |
| + commands: { "/questpin": (args, ctx) => ctx.emit({ type: "questpin.set", id: ctx.id() }) }, |
| + render() {}, // groupAppsFor requires a render to count as addable to a group |
| +}); |
| + |
| +await test("quiet + groupCapable: PERSONAL routing (not added here) still resolves the app-thread", async () => { |
| + let resolves = 0; |
| + setAppThreadResolver(async () => { |
| + resolves++; |
| + return "questpinThread"; |
| + }); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| + assert.equal(runCommand("/questpin", "chatX"), true); |
| + await tick(); |
| + setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| + assert.equal(resolves, 1, "quiet doesn't change WHETHER the personal thread resolves, only its visibility"); |
| + assert.equal(sent[0].threadId, "questpinThread"); |
| +}); |
| + |
| +await test("quiet + groupCapable: ADDED to a group targets the group thread — quiet's create/hide path never runs", async () => { |
| + resetThread("questpinGrp"); |
| + foldEvent("questpinGrp", { type: "groupapps.add", app: "questpin", name: "Ana" }, { id: 1 }); |
| + // Resolver poisoned: the group's shared instance is the nearer scope, so |
| + // resolveAppThread (and therefore createAppThread's hide-on-create logic) |
| + // must never even be consulted — quiet has nothing to do here. |
| + setAppThreadResolver(async () => { |
| + throw new Error("group-scoped commands never route to the personal thread"); |
| + }); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| + assert.equal(runCommand("/questpin", "questpinGrp"), true); |
| + await tick(); |
| + setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| + assert.equal(sent.length, 1); |
| + assert.equal(sent[0].threadId, "questpinGrp", "targets the GROUP thread, not a personal one"); |
| +}); |
| + |
| +await test("parseSides reads a die size, defaults to d6, rejects out-of-range/garbage", () => { |
| + assert.equal(parseSides(""), 6); |
| + assert.equal(parseSides(" "), 6); |
| + assert.equal(parseSides("20"), 20); |
| + assert.equal(parseSides("d20"), 20); |
| + assert.equal(parseSides("D20"), 20); |
| + assert.equal(parseSides("1"), null, "below the 2-side floor"); |
| + assert.equal(parseSides("5000"), null, "above the 1000-side ceiling"); |
| + assert.equal(parseSides("banana"), null); |
| +}); |
| + |
| +await test("dice.js's reducer folds a roll and ignores malformed events", () => { |
| + const reduce = diceApp.reduce; |
| + let s = reduce(undefined, { type: "dice.roll", id: "r1", sides: 20, result: 17, name: "Ana" }); |
| + assert.deepEqual(s.rolls, [{ id: "r1", sides: 20, result: 17, by: "Ana" }]); |
| + // A second, well-formed roll appends; a malformed one (missing result) no-ops. |
| + s = reduce(s, { type: "dice.roll", id: "r2", sides: 6, result: 3 }); |
| + s = reduce(s, { type: "dice.roll", id: "r3", sides: 6 }); |
| + assert.equal(s.rolls.length, 2); |
| + assert.equal(s.rolls[1].result, 3); |
| +}); |
| + |
| +await test("/roll emits a dice.roll event and replies in place with an Open action", async () => { |
| + setAppThreadResolver(async () => "diceThread"); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| + const replies = []; |
| + assert.equal(runCommand("/roll d20", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + setAppThreadResolver(async () => "todoThread"); // restore for later tests |
| + assert.equal(sent.length, 1); |
| + assert.equal(sent[0].threadId, "diceThread"); |
| + assert.equal(sent[0].event.type, "dice.roll"); |
| + assert.equal(sent[0].event.sides, 20); |
| + assert.ok(Number.isInteger(sent[0].event.result) && sent[0].event.result >= 1 && sent[0].event.result <= 20); |
| + assert.equal(replies.length, 1); |
| + assert.match(replies[0].text, /Rolled a \d+ on a d20/); |
| + assert.equal(replies[0].actions[0].label, "Roll history"); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs| index f27a560..08b9729 100644 |
| --- a/test/sandbox.test.mjs |
| +++ b/test/sandbox.test.mjs |
| @@ -20,6 +20,7 @@ import { |
| isAppType, |
| appByName, |
| groupCapableApps, |
| + appIsQuiet, |
| } from "../web/static/apps.js"; |
| import { |
| addItem, |
| @@ -253,6 +254,20 @@ test("manifestOf derives a valid manifest from an evaluated spec", () => { |
| "gx", |
| ); |
| assert.equal(manifestOf(grouped).groupCapable, true); |
| + |
| + // quiet is read verbatim too (issue #227), same as groupCapable — a trust/ |
| + // behavior decision the author declares, not derivable from code shape. |
| + const quiet = evalMemberApp( |
| + `import { defineApp } from "../apps.js"; |
| + const app = defineApp({ |
| + name: "qx", title: "QX", glyph: "Q", quiet: true, |
| + initialState: () => ({}), events: { "qx.a": (s) => s }, render() {}, |
| + });`, |
| + "qx", |
| + ); |
| + assert.equal(manifestOf(quiet).quiet, true); |
| + assert.equal(manifestOf(grouped).quiet, false); // not declared → false, not just absent |
| + assert.equal(validateManifest(manifestOf(quiet)), null); |
| }); |
| test("normalizeCommands accepts a bare function or a {run} object", () => { |
| @@ -439,6 +454,40 @@ test("a groupCapable member app registers as group-capable; a personal one doesn |
| assert.ok(!groupCapableApps().some((a) => a.name === "gplain")); |
| }); |
| +// A member app opts into `quiet` (issue #227) exactly like groupCapable — a |
| +// boolean the author declares, read straight off the manifest, no different |
| +// from a built-in app's `quiet`. Same mechanism (appIsQuiet), same enforcement |
| +// (shell.js), for both origins. |
| +test("a member app can declare quiet:true; validateManifest rejects a non-boolean", () => { |
| + const quiet = { v: 1, name: "qmember", title: "Quiet", glyph: "Q", types: ["qmember.set"], quiet: true }; |
| + const res = registerMemberApp({ id: "app-quiet1", manifest: quiet, source: NOTES_APP.replace(/notes/g, "qmember"), author: "Ana" }); |
| + assert.equal(res.ok, true, res.reason); |
| + assert.equal(appByName("qmember").quiet, true); |
| + assert.equal(appIsQuiet("qmember"), true); |
| + |
| + const notQuiet = { v: 1, name: "notqmember", title: "Loud", glyph: "L", types: ["notqmember.set"] }; |
| + const res2 = registerMemberApp({ id: "app-quiet2", manifest: notQuiet, source: NOTES_APP.replace(/notes/g, "notqmember") }); |
| + assert.equal(res2.ok, true, res2.reason); |
| + assert.equal(appByName("notqmember").quiet, undefined); |
| + assert.equal(appIsQuiet("notqmember"), false); |
| + |
| + assert.equal(validateManifest({ ...quiet, quiet: "yes" }), "bad quiet flag"); |
| +}); |
| + |
| +// quiet and groupCapable are orthogonal, same as for built-in apps: a member |
| +// app can declare both, and quiet only ever governs the PERSONAL thread's |
| +// visibility — it carries no meaning for the shared group instance. |
| +test("a member app can be both groupCapable and quiet at once", () => { |
| + const both = { v: 1, name: "qgroup", title: "QG", glyph: "Q", types: ["qgroup.set"], groupCapable: true, quiet: true }; |
| + const res = registerMemberApp({ id: "app-quiet3", manifest: both, source: NOTES_APP.replace(/notes/g, "qgroup") }); |
| + assert.equal(res.ok, true, res.reason); |
| + const app = appByName("qgroup"); |
| + assert.equal(app.groupCapable, true); |
| + assert.equal(app.quiet, true); |
| + assert.equal(app.scope, "personal"); |
| + assert.ok(groupCapableApps().some((a) => a.name === "qgroup")); |
| +}); |
| + |
| test("installMemberAppState preserves the `lost` overflow flag", () => { |
| const manifest = { v: 1, name: "lossyapp", title: "L", glyph: "L", types: ["lossyapp.e"] }; |
| const res = registerMemberApp({ id: "app-lost1", manifest, source: NOTES_APP.replace(/notes/g, "lossyapp") }); |
diff --git a/web/static/apps.js b/web/static/apps.js| index 9977d03..5d77b4e 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -115,6 +115,17 @@ injectStyles( |
| // `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. |
| +// quiet: true — a command answering in place (ctx.reply/ctx.panel, or an event |
| +// it emits) must never itself force the app's PERSONAL thread into view. |
| +// The thread still exists and still receives every event — it's the |
| +// only place the interaction is durably recorded — this only affects |
| +// whether it's SHOWN: brand new → created but left hidden; already |
| +// visible → unaffected; was visible then closed → activity doesn't |
| +// reopen it (the user's own tap, e.g. an action-chip ctx.open(), still |
| +// does). See shell.js's createAppThread + the "msg" WS handler, and |
| +// issue #227. Irrelevant to a groupCapable app's shared instance — that |
| +// thread is the chat the user is already looking at, never created by |
| +// the command. |
| // 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 }; |
| @@ -361,6 +372,15 @@ export function personalApps() { |
| return apps.filter((a) => a.personal); |
| } |
| +// appIsQuiet: does this app opt out of forcing its thread into view on mere |
| +// activity (see `quiet` in defineApp's doc comment, and issue #227)? Pure |
| +// lookup by app name so both the create-time and the reopen-on-activity call |
| +// sites in shell.js share one answer. |
| +export function appIsQuiet(name) { |
| + const app = name && appByName(name); |
| + return !!(app && app.quiet); |
| +} |
| + |
| // appWantsChatView decides how a personal app-thread presents. An app that |
| // implements chatMessage(event) — the hook projecting its events into chat |
| // bubbles / action lines — opts into the COMPANION view: a Chat conversation of |
diff --git a/web/static/apps/README.md b/web/static/apps/README.md| index 2823a2d..241bbf0 100644 |
| --- a/web/static/apps/README.md |
| +++ b/web/static/apps/README.md |
| @@ -206,6 +206,7 @@ without naming the secret in the line. |
| | `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". | |
| | `groupChatOnly`| | 🤖 Pairs with a `chatMessage` hook: the app still projects into a GROUP chat's feed, but its own personal thread stays a takeover (never grows a Chat view). See "Companion projection" above; `vault.js` is the example. | |
| | `listed` | | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `true`. | |
| +| `quiet` | | 🤖 `true` if a command answering in place must never itself force this app's PERSONAL thread into view. The thread still exists and still gets every event — that's the only durable record of the interaction — this only governs visibility. See "Quiet apps" below and issue #227. | |
| | `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. | |
| @@ -370,6 +371,44 @@ render.) Three rules keep this honest: |
| A default-command thread is text-only: the composer hides its attach affordances |
| (there's no one to read a file), and they return if a bot joins. |
| +### Quiet apps |
| + |
| +🤖 A personal command answers **in place** by design (CLAUDE.md), but by |
| +default it still has a side effect on your sidebar: the first time you run |
| +a personal app's global command, its app-thread is created — via |
| +`resolveAppThread`/`createAppThread` in `shell.js` — and appears in the |
| +thread list, whether or not the command actually did anything. That's right |
| +for To-do or Notes (the thread IS where the item now lives, so surfacing it |
| +is the point), but wrong for an app whose commands mostly just answer a |
| +question or offer an action chip — it shouldn't spawn a sidebar entry the |
| +user never asked for. |
| + |
| +Set **`quiet: true`** and the app's personal thread stops forcing its own way |
| +into view. The thread still exists — it's still the only durable record of |
| +the interaction, and every event you `ctx.emit` still lands there exactly as |
| +before — `quiet` only changes *visibility*, never storage: |
| + |
| +- **Brand new** (never interacted with before): the thread is created (so |
| + your command has somewhere to emit into) but stays hidden — no new sidebar |
| + row. |
| +- **Already visible** (the user has it open or it's in their list): nothing |
| + changes. Quiet is a no-op once the thread is already showing. |
| +- **Was visible, then the user closed it**: your command's activity — a |
| + reply, a panel, or an emitted event — must not reopen it. Only the user's |
| + own tap (an action chip's `ctx.open`, or finding it via the Apps menu) |
| + brings it back, and when it does, every event sent while it was hidden is |
| + already there waiting. |
| + |
| +You don't have to do anything else to get this — no different `ctx.emit`, |
| +no different reply shape. The enforcement lives entirely in the host |
| +(`createAppThread` creates it pre-hidden; the WS message handler reasserts |
| +the hide instead of letting the server's default "new activity reopens a |
| +closed chat" rule apply) because the **server never learns which app owns a |
| +thread** (see CLAUDE.md), so only the client can make this call. See issue |
| +#227. `quiet` only means something for a **personal** app-thread — it has no |
| +effect on a `groupCapable` app's shared group instance, since that thread is |
| +the chat the user already has open, never one the command creates. |
| + |
| ### Command panels — a transient results surface |
| A command can answer with more than a private reply: `ctx.panel(spec)` opens a |
diff --git a/web/static/disabled-apps/dice.js b/web/static/disabled-apps/dice.js| new file mode 100644 |
| index 0000000..5a1f685 |
| --- /dev/null |
| +++ b/web/static/disabled-apps/dice.js |
| @@ -0,0 +1,89 @@ |
| +// 🤖 DISABLED — not wired into shell.js, not shipped in the live app. This was |
| +// the proof-of-concept for `quiet: true` (apps/README.md § Quiet apps, issue |
| +// #227): a personal app whose `/roll` answers right where you typed it and |
| +// still records every roll into its own Dice thread, but that thread never |
| +// forces its own way into the sidebar — the reducer, the command, and the |
| +// `quiet` declaration below are exactly what a real quiet app looks like. |
| +// Kept here (not deleted) as a living reference and for its unit tests |
| +// (test/apps.test.mjs); it never ran live outside a dev-server verification |
| +// pass. Move it back to web/static/apps/ + re-add the shell.js import if it's |
| +// ever wanted as a real feature. |
| +// |
| +// The dice mini-app: `/roll` (or `/roll d20`) answers right where you typed |
| +// it — a QUIET personal app. Every roll is still recorded into your own Dice |
| +// thread, so your history is real and syncs across devices — quiet only |
| +// means the thread never forces its own way into your sidebar; your own tap |
| +// (the "Roll history" chip, or the Apps menu) opens it. |
| +import { defineApp, addItem } from "../apps.js"; |
| + |
| +const ROLL_RE = /^d?(\d{1,3})$/i; |
| + |
| +// parseSides pulls the die size out of "/roll d20" → 20, defaulting to a d6 |
| +// for a bare "/roll", or null for anything unparseable or out of range (2..1000). |
| +// Pure, so it's unit-testable in Node. |
| +export function parseSides(args) { |
| + const text = (args || "").trim(); |
| + if (!text) return 6; |
| + const m = ROLL_RE.exec(text); |
| + if (!m) return null; |
| + const n = parseInt(m[1], 10); |
| + return n >= 2 && n <= 1000 ? n : null; |
| +} |
| + |
| +export const app = defineApp({ |
| + name: "dice", |
| + title: "Dice", |
| + glyph: "🎲", |
| + personal: true, |
| + quiet: true, // the whole point: /roll never forces the Dice thread into view |
| + initialState: () => ({ rolls: [] }), |
| + events: { |
| + "dice.roll": (s, e) => |
| + Number.isInteger(e.sides) && Number.isInteger(e.result) |
| + ? { rolls: addItem(s.rolls, { id: e.id, sides: e.sides, result: e.result, by: e.name || "" }) } |
| + : s, |
| + }, |
| + commands: { |
| + "/roll": { |
| + desc: "Roll a die", |
| + args: "[dN]", |
| + run(args, ctx) { |
| + const sides = parseSides(args); |
| + if (sides === null) return ctx.reply({ text: "Usage: /roll or /roll d20 — a die between d2 and d1000." }); |
| + const result = 1 + Math.floor(Math.random() * sides); |
| + ctx.emit({ type: "dice.roll", id: ctx.id(), sides, result }); |
| + ctx.reply({ |
| + text: `🎲 Rolled a ${result} on a d${sides}.`, |
| + short: `rolled ${result} (d${sides})`, |
| + actions: [{ label: "Roll history", run: ctx.open }], |
| + }); |
| + }, |
| + }, |
| + }, |
| + styles: ` |
| +.dice-item { display: flex; justify-content: space-between; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); } |
| +.dice-result { font-weight: 600; } |
| +.dice-empty { color: var(--muted); padding: 8px 2px; } |
| +`, |
| + render(state, el) { |
| + const rolls = (state && state.rolls) || []; |
| + if (!rolls.length) { |
| + const empty = document.createElement("div"); |
| + empty.className = "dice-empty"; |
| + empty.textContent = "No rolls yet — try /roll from any chat."; |
| + el.appendChild(empty); |
| + return; |
| + } |
| + for (const r of [...rolls].reverse()) { |
| + const row = document.createElement("div"); |
| + row.className = "dice-item"; |
| + const label = document.createElement("span"); |
| + label.textContent = `d${r.sides}`; |
| + const result = document.createElement("span"); |
| + result.className = "dice-result"; |
| + result.textContent = String(r.result); |
| + row.append(label, result); |
| + el.appendChild(row); |
| + } |
| + }, |
| +}); |
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js| index 01a6553..4fde09a 100644 |
| --- a/web/static/sandbox-frame.js |
| +++ b/web/static/sandbox-frame.js |
| @@ -327,6 +327,9 @@ export function manifestOf(spec) { |
| // trust decision the author makes explicitly (defineApp({ groupCapable: |
| // true, ... })), so we just read what they declared. |
| groupCapable: !!spec.groupCapable, |
| + // quiet: same idea — a behavior decision the author declares explicitly |
| + // (defineApp({ quiet: true, ... })), not derivable from code shape. |
| + quiet: !!spec.quiet, |
| }; |
| // defaultCommand: the command plain (non-slash) text runs in the app's Chat |
| // view (like To-do's /add). Host-side routing hint — validateManifest checks it |
diff --git a/web/static/sandbox.js b/web/static/sandbox.js| index 5c5a9a5..50932d4 100644 |
| --- a/web/static/sandbox.js |
| +++ b/web/static/sandbox.js |
| @@ -114,6 +114,10 @@ export function validateManifest(m) { |
| // `groupCapable` — see apps.js). Orthogonal to `scope`, which stays |
| // "personal": the app still gets its own personal thread too. |
| if (m.groupCapable !== undefined && typeof m.groupCapable !== "boolean") return "bad groupCapable flag"; |
| + // Optional quiet opt-in: a command answering in place must not force this |
| + // app's personal thread into view (same meaning as a built-in app's `quiet` |
| + // — see apps.js, issue #227). |
| + if (m.quiet !== undefined && typeof m.quiet !== "boolean") return "bad quiet flag"; |
| // Optional defaultCommand: plain text in the Chat view runs it. Must name a |
| // command the app declares (so the host has a handler to route text to). |
| if (m.defaultCommand !== undefined) { |
| @@ -770,6 +774,10 @@ export function registerMemberApp({ id, manifest, source, author }) { |
| // groupCapableApps() (apps.js) lists it in "Add apps" and the ordinary |
| // groupapps.add/remove + per-thread fold machinery does the rest. |
| ...(manifest.groupCapable ? { groupCapable: true } : {}), |
| + // quiet: the author opted this app into never forcing its personal thread |
| + // into view on mere activity (manifest.quiet — same meaning and same |
| + // enforcement path as a built-in app's `quiet`, see apps.js + issue #227). |
| + ...(manifest.quiet ? { quiet: true } : {}), |
| // memberApp marks the proxy (apps.js gates installMemberAppState on it) |
| // and carries what the frame needs at init. |
| memberApp: { id, source, manifest, author: author || "" }, |
diff --git a/web/static/shell.js b/web/static/shell.js| index d49023a..7825f9e 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -86,6 +86,7 @@ import { |
| renderSurfaces, |
| renderApp as renderMiniApp, |
| appByName, |
| + appIsQuiet, |
| appForType, |
| appWantsChatView, |
| appDefaultView, |
| @@ -5052,6 +5053,17 @@ async function createAppThread(appName) { |
| threadApp[r.thread_id] = appName; |
| const a = appByName(appName); |
| threadName[r.thread_id] = a ? a.title || a.name : appName; |
| + // A quiet app's thread exists (so its events have somewhere to land) but must |
| + // not force its own way into view on first use — hide it server-side before |
| + // loadThreads() ever has a chance to render it visible (issue #227). |
| + if (appIsQuiet(appName)) { |
| + hidden[r.thread_id] = true; |
| + await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/hidden`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, hidden: true }), |
| + }).catch(() => {}); |
| + } |
| await loadThreads(); |
| renderThreadList(); |
| return r.thread_id; |
| @@ -6478,8 +6490,22 @@ function connectWS() { |
| } catch {} |
| return; |
| } |
| - // New activity reopens a closed chat (the server clears its hidden flag too). |
| - hidden[tid] = false; |
| + // New activity normally reopens a closed chat — the server clears its hidden |
| + // flag on every message insert (db.go's insertMessage), unconditionally, |
| + // because it never learns which app owns a thread (see CLAUDE.md) and so |
| + // can't tell a quiet app's activity from anything else. A quiet app's |
| + // thread must stay exactly as the user left it, so when activity lands in |
| + // one we're keeping hidden, reassert the hide the server just cleared |
| + // (issue #227) — otherwise the very next reload would show it again. |
| + if (hidden[tid] && appIsQuiet(threadApp[tid])) { |
| + fetch(`/api/threads/${encodeURIComponent(tid)}/hidden`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, hidden: true }), |
| + }).catch(() => {}); |
| + } else { |
| + hidden[tid] = false; |
| + } |
| // Decrypt once up front: the payload feeds the list preview, and the sender |
| // identity inside it decides the unread dot — your own message (sent from |
| // another device or tab) must not light the chat up, and replying elsewhere |