eleven / messenger Public

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>
James Adam 35ea94aa952a9233fb0c69bb43d2ec84539fb1a1 parent c79bbe5
9 files changed, +369 −2
  • docs/building-member-apps.md +1 −0
  • test/apps.test.mjs +132 −0
  • test/sandbox.test.mjs +49 −0
  • web/static/apps.js +20 −0
  • web/static/apps/README.md +39 −0
  • web/static/disabled-apps/dice.js +89 −0
  • web/static/sandbox-frame.js +3 −0
  • web/static/sandbox.js +8 −0
  • web/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