eleven / messenger Public

web: group-scoped trusted apps — shared To-do, Reminders & Bookmarks in group chats (#400)

* web: group-scoped trusted apps — add To-do to a group chat

A groupCapable personal app (To-do) can now also be added to a group
(⋮ → Add apps), where one shared instance runs for everyone. The existing
model does the heavy lifting: the app's ordinary events post into the GROUP
thread's encrypted log, so every member folds the same state — same events,
same reducer, different thread — and snapshots, live delivery, and E2EE all
apply unchanged. Which apps a group has added is itself fold state, from new
groupapps.add/.remove wire events owned by a framework-level registry app,
so the server never learns which app was added (or that one was).

- Header widget bar: groups with added apps get the companion view toggle
  (Chat | Split | one glyph button per added app), rendering the app in the
  same Interface pane with ctx bound to the group thread.
- Chat projection is attributed: action lines lead with the actor
  ("Ana added “buy milk”"); app-voice bubbles stay the app's.
- "@To-do buy milk" runs the app's default command in place (the event is
  the record, no message posted); added apps join the @ typeahead; a bare
  mention answers with mention-form usage, privately.
- To-do opts in (groupCapable) and gains sidebar presence on adds
  (preview + unread) so groups notice shared-list activity.
- Vectors: test/event-vectors/groupapps.json is the cross-platform spec;
  reducer/mention/registry tests in test/apps.test.mjs; verified end-to-end
  in Chromium against a local build (add → widget bar → shared list →
  mention → reload refold → remove).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q1JpjanMC2hHQBQWB7dp4

* web: /command scopes to the group's app instance when the app is added there

A global personal command (/todo) used to route to your personal app-thread
from anywhere — so typing it in a group that has To-do ADDED spawned/opened a
personal instance instead of acting on the shared list. Now runCommand checks
groupAppsFor(thread) first: with the app added to the chat you're typing in,
the group's shared instance is the nearer scope and the command acts right
there, exactly like "@To-do". Everywhere else the personal routing is
unchanged, so the same verb always addresses the instance you're looking at.
In-thread the folded action line is the record, so no separate confirmation.

Verified end-to-end in Chromium: /todo in the group grows the shared list,
projects an attributed action line, and no personal To-do thread appears in
the sidebar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q1JpjanMC2hHQBQWB7dp4

* web: widen the group-app roster to Reminders + Bookmarks; sheet polish

Every app whose model makes sense shared is now groupCapable, not just To-do:

- Reminders: shared group reminders — the server delivers remind.fire into
  the GROUP thread, so every member folds it, sees the bubble, and gets the
  push. chatMessage now takes a { group: true } hint (passed by the group
  projection) so the fire bubble reads "Reminder: …" for many readers instead
  of the personal "You asked me to remind you: …".
- Bookmarks: a shared link list; /save scopes to the group instance. The
  "Save link" message action still files into YOUR bookmarks on purpose.

Deliberately NOT flagged: Integrations and DNSimple (state derived from one
member's private service credentials — sharing needs its own design), and
Poll/Whiteboard (already scope:"shared", the always-available panel model).
The per-app gate is the point; README documents the roster and reasoning.

Also: drop the floating divider under a pick-list's last row (the border is a
between-rows separator), per PR feedback.

Verified end-to-end in Chromium (31 checks): the sheet offers all three and
excludes credential-backed apps; two added apps get separate widget buttons
and tapping swaps the pane in place; removing one keeps the bar, removing the
last clears it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q1JpjanMC2hHQBQWB7dp4

* web: second tap on a group app's widget button gives it the full pane

Tapping an added app's glyph while already in Split view did nothing — the
handler only knew "bring into view" and "swap which app", so with one app the
tap was a dead end, and the full-pane Interface view was unreachable in groups
on desktop. Now the glyph escalates: from Chat it opens the app beside the
conversation (full-pane on mobile, which has no split), a second tap gives it
the whole pane, and tapping a different app's glyph keeps the current view
and swaps the pane in place. The Chat / Split segments restore the other
layouts as before; tapping the already-active segment stays a no-op, like any
segmented control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q1JpjanMC2hHQBQWB7dp4

* web: "/command @here" installs + shares; the / menu says where a command lands

A general pattern for reaching a shared app from a group that doesn't have it
yet: a leading "@here" in a group-capable personal command's arguments
("/todo @here buy milk") installs the app into the group — the same
groupapps.add the Add-apps sheet emits, so everyone gets the widget bar and
the action line — and then runs the command against the new shared instance.
A bare "/todo @here" is just the install. Anywhere the flag can't apply (not
a group, or an app that can't be shared) it answers with a private
explanation instead of silently mis-filing the text. parseHere is pure and
strict: leading token only, breaking on whitespace.

To make both the routing and the flag discoverable, a group-capable command's
"/" typeahead (and /help) description now says where it will land in a group:
"… · shared in this chat" when the app is added, "… · yours — @here to share"
when it isn't. shell injects the group check (setThreadIsGroup); absent
injection degrades to no hints and no @here, never a mis-route.

Verified end-to-end in Chromium (38 checks), including: hint shown before
install, "/todo @here camp chairs" raises the widget bar + files the item
with no personal thread spawned, and the hint flips to "shared in this chat".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q1JpjanMC2hHQBQWB7dp4

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam ad7dbd8d74c065d4bc64b3c58a76bdb03c32f62e parent 34e2dc4
15 files changed, +974 −45
  • .claude/memory/event-contract.md +1 −0
  • changelog/2026-07-08-group-apps.md +15 −0
  • test/apps.test.mjs +222 −3
  • test/event-vectors.test.mjs +19 −0
  • test/event-vectors/groupapps.json +123 −0
  • test/event-vectors/index.js +2 −0
  • web/pages/index.html +1 −0
  • web/static/apps.js +206 −7
  • web/static/apps/README.md +69 −0
  • web/static/apps/bookmarks.js +5 −0
  • web/static/apps/chat.js +17 −2
  • web/static/apps/remind.js +13 −3
  • web/static/apps/todo.js +16 −1
  • web/static/shell.js +262 −29
  • web/static/style.css +3 −0
diff --git a/.claude/memory/event-contract.md b/.claude/memory/event-contract.md
index 08f28d2..b700122 100644
--- a/.claude/memory/event-contract.md
+++ b/.claude/memory/event-contract.md
@@ -15,6 +15,7 @@ metadata:
- `test/event-vectors/chat.json` — message/reaction/edit/delete vectors (web: source of truth)
- `test/event-vectors/todo.json` — to-do fold logic vectors (web: source of truth)
- `test/event-vectors/remind.json` — remind fold logic vectors (web: source of truth)
+- `test/event-vectors/groupapps.json` — group-apps registry vectors: which trusted apps a group chat has added (web source of truth: the `groupapps` app in `web/static/apps.js`)
- `test/event-vectors/index.js` — loader for tests
**Maintenance:**
diff --git a/changelog/2026-07-08-group-apps.md b/changelog/2026-07-08-group-apps.md
new file mode 100644
index 0000000..21b5401
--- /dev/null
+++ b/changelog/2026-07-08-group-apps.md
@@ -0,0 +1,15 @@
+🤖 Apps can now join your group chats. Open a group's ⋮ menu, tap "Add apps",
+and add To-do, Reminders, or Bookmarks: everyone in the group gets one shared
+list, sitting right beside the conversation. New buttons appear in the header
+for the whole group — switch between the chat, an app, or both side by side on
+a bigger screen — and every change syncs live for every member (a shared
+reminder fires for the whole group). You can also just type "@To-do buy milk"
+(or "/todo buy milk") in the conversation to add one without leaving the chat —
+in a group that has To-do, both go to the group's shared list.
+In a group that doesn't have the app yet, "/todo @here buy milk" adds it and
+files the first item in one go — and the command menu now says where each
+command will land: this chat's shared app, or your personal one (with "@here
+to share" as the nudge when it's the latter).
+Your personal lists stay your own; the group's live in the group — end-to-end
+encrypted like everything else, so the server never sees a to-do or even which
+app you added.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 6ae3875..e09aaf6 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -43,6 +43,13 @@ import {
awaitAnswer,
cancelAnswer,
deliverAnswer,
+ groupAppsApp,
+ groupAppsFor,
+ groupCapableApps,
+ parseAppMention,
+ parseHere,
+ runAppMention,
+ setThreadIsGroup,
} 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";
@@ -984,9 +991,12 @@ await test("apps opt into sidebar presence: poll previews + marks unread on crea
// Votes stay silent — the tally updates live anyway.
assert.equal(appPreview({ type: "poll.vote", pollId: "p", choice: 0 }), null);
assert.equal(appUnread({ type: "poll.vote", pollId: "p" }), false);
- // Apps without the hooks keep the old behaviour: silent in the sidebar.
- assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk" }), null);
- assert.equal(appUnread({ type: "todo.add", id: "a" }), false);
+ // To-do opts in for adds (how a group notices shared-list activity); its
+ // quiet events (done/reopen/remove) stay silent.
+ assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk”");
+ assert.equal(appUnread({ type: "todo.add", id: "a" }), true);
+ assert.equal(appPreview({ type: "todo.done", id: "a" }), null);
+ assert.equal(appUnread({ type: "todo.done", id: "a" }), false);
// Unowned types are silent too.
assert.equal(appPreview({ type: "mystery.event" }), null);
});
@@ -1284,5 +1294,214 @@ await test("conversational answers: attachments ride the payload (the photo step
assert.deepEqual(got, [["", 1]], "an attachment-only send still answers the waiter");
});
+// ---- group-scoped apps (groupapps registry + @-mention routing) ------------
+
+const groupAppsReduce = groupAppsApp.reduce;
+
+await test("groupapps: adds fold idempotently, removes drop, malformed no-op", () => {
+ let s = groupAppsReduce(undefined, { type: "groupapps.add", app: "todo", name: "Ana", ts: 5 });
+ assert.deepEqual(s, { apps: [{ id: "todo", by: "Ana", ts: 5 }] });
+ // Re-adding (another member, a refold) is a no-op — the item id IS the app name.
+ const s2 = groupAppsReduce(s, { type: "groupapps.add", app: "todo", name: "Bob", ts: 9 });
+ assert.deepEqual(s2.apps, s.apps);
+ // Unknown app names still fold (state converges across client versions).
+ s = groupAppsReduce(s, { type: "groupapps.add", app: "flarp", name: "Bob", ts: 9 });
+ assert.equal(s.apps.length, 2);
+ s = groupAppsReduce(s, { type: "groupapps.remove", app: "todo" });
+ assert.deepEqual(s.apps.map((a) => a.id), ["flarp"]);
+ // Hostile / malformed events are no-ops, never a crash (Invariant #8).
+ assert.equal(groupAppsReduce(s, { type: "groupapps.add" }), s);
+ assert.equal(groupAppsReduce(s, { type: "groupapps.add", app: 7 }), s);
+ assert.deepEqual(groupAppsReduce(s, { type: "groupapps.remove", app: "missing" }).apps, s.apps);
+});
+
+await test("groupapps: add/remove project as action lines; previews name the actor", () => {
+ assert.deepEqual(groupAppsApp.chatMessage({ type: "groupapps.add", app: "todo" }), { text: "added To-do to this chat", action: true });
+ assert.deepEqual(groupAppsApp.chatMessage({ type: "groupapps.remove", app: "todo" }), { text: "removed To-do from this chat", action: true });
+ // An app this client doesn't know still reads sensibly.
+ assert.equal(groupAppsApp.chatMessage({ type: "groupapps.add", app: "flarp" }).text, "added flarp to this chat");
+ assert.equal(appPreview({ type: "groupapps.add", app: "todo", name: "Ana" }), "Ana added To-do");
+ assert.equal(appPreview({ type: "groupapps.remove", app: "todo", name: "Ana" }), "Ana removed To-do");
+});
+
+await test("groupAppsFor resolves added apps, filtering unknown and non-group-capable", () => {
+ resetThread("grp1");
+ foldEvent("grp1", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ foldEvent("grp1", { type: "groupapps.add", app: "flarp", name: "Ana" }, { id: 2 }); // unknown → inert
+ foldEvent("grp1", { type: "groupapps.add", app: "poll", name: "Ana" }, { id: 3 }); // not groupCapable → inert
+ assert.deepEqual(groupAppsFor("grp1").map((a) => a.name), ["todo"]);
+ foldEvent("grp1", { type: "groupapps.remove", app: "todo" }, { id: 4 });
+ assert.deepEqual(groupAppsFor("grp1"), []);
+ resetThread("grp1");
+});
+
+await test("the group-capable roster: shared-list apps in, credential-backed apps out", () => {
+ const names = groupCapableApps().map((a) => a.name);
+ for (const n of ["todo", "remind", "bookmarks"]) assert.ok(names.includes(n), n + " is addable to groups");
+ // Their state derives from one member's private service credentials — sharing
+ // those needs its own design, so they stay personal-only on purpose.
+ for (const n of ["connect", "dnsimple"]) assert.ok(!names.includes(n), n + " stays personal-only");
+ // Poll/Whiteboard already run in every chat via the shared-panel model.
+ for (const n of ["poll", "board"]) assert.ok(!names.includes(n), n + " is scope:shared, not added");
+});
+
+await test("Reminders' fire bubble drops 'you asked me' in a group", () => {
+ const e = { type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "call mum" } };
+ assert.equal(remindApp.chatMessage(e), "You asked me to remind you: “call mum”");
+ assert.equal(remindApp.chatMessage(e, { group: true }), "Reminder: “call mum”");
+ // The add action line is host-prefixed with the actor either way.
+ const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "call mum", when: 0 }, { group: true });
+ assert.equal(add.action, true);
+});
+
+await test("parseAppMention matches an added app by title or name, leading-@ only", () => {
+ resetThread("grp2");
+ foldEvent("grp2", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ let m = parseAppMention("@To-do buy milk", "grp2");
+ assert.equal(m.app.name, "todo");
+ assert.equal(m.args, "buy milk");
+ m = parseAppMention("@to-DO buy milk", "grp2"); // case-insensitive
+ assert.equal(m.args, "buy milk");
+ m = parseAppMention("@todo buy milk", "grp2"); // the app name works too
+ assert.equal(m.args, "buy milk");
+ assert.equal(parseAppMention("@To-do", "grp2").args, "", "a bare mention parses with empty args");
+ // Must break on whitespace — "@To-dos…" is conversation, not an address.
+ assert.equal(parseAppMention("@To-dos are great", "grp2"), null);
+ assert.equal(parseAppMention("hey @To-do buy milk", "grp2"), null, "leading-@ only");
+ assert.equal(parseAppMention("@To-do buy milk", "grpEmpty"), null, "no apps added → no match");
+ resetThread("grp2");
+});
+
+await test("runAppMention emits the default command's event into the GROUP thread", async () => {
+ resetThread("grp3");
+ foldEvent("grp3", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ // Resolver poisoned: a group mention must NOT route to the personal app-thread.
+ setAppThreadResolver(async () => {
+ throw new Error("group mentions never route");
+ });
+ assert.equal(runAppMention("@To-do buy milk", "grp3", (r) => replies.push(r)), true);
+ await tick();
+ setAppThreadResolver(async () => "todoThread"); // restore for later tests
+ assert.equal(sent.length, 1);
+ assert.equal(sent[0].threadId, "grp3", "the event lands in the group thread");
+ assert.equal(sent[0].event.type, "todo.add");
+ assert.equal(sent[0].event.text, "buy milk");
+ // The success confirmation is suppressed — the folded action line is the record.
+ assert.deepEqual(replies, []);
+ resetThread("grp3");
+});
+
+await test("/todo typed in a chat with To-do ADDED targets the GROUP instance", async () => {
+ resetThread("grp5");
+ foldEvent("grp5", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ // Resolver poisoned: with the app added here, /todo must NOT open a personal
+ // app-thread — the group's shared instance is the nearer scope.
+ setAppThreadResolver(async () => {
+ throw new Error("group-scoped commands never route to the personal thread");
+ });
+ assert.equal(runCommand("/todo group milk", "grp5", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(sent.length, 1);
+ assert.equal(sent[0].threadId, "grp5", "the event lands in the group thread");
+ assert.equal(sent[0].event.type, "todo.add");
+ assert.equal(sent[0].event.text, "group milk");
+ // In-thread the folded action line is the record — no separate confirmation.
+ assert.deepEqual(replies, []);
+ setAppThreadResolver(async () => "todoThread"); // restore for later tests
+ // Without the app added (any other chat), /todo still routes personally.
+ const sent2 = [];
+ setEmitTransport((threadId, event) => (sent2.push({ threadId, event }), Promise.resolve(true)));
+ assert.equal(runCommand("/todo personal milk", "chatX"), true);
+ await tick();
+ assert.equal(sent2[0].threadId, "todoThread");
+ resetThread("grp5");
+});
+
+await test("parseHere: leading @here only, breaking on whitespace", () => {
+ assert.deepEqual(parseHere("@here buy milk"), { rest: "buy milk" });
+ assert.deepEqual(parseHere("@here"), { rest: "" });
+ assert.deepEqual(parseHere(" @here buy milk "), { rest: "buy milk" });
+ assert.equal(parseHere("@heresy is text"), null);
+ assert.equal(parseHere("buy milk @here"), null);
+ assert.equal(parseHere(""), null);
+});
+
+await test("/todo @here installs the app into the group AND files the item there", async () => {
+ resetThread("grp6");
+ setThreadIsGroup((id) => id === "grp6");
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ setAppThreadResolver(async () => {
+ throw new Error("@here never routes to the personal thread");
+ });
+ assert.equal(runCommand("/todo @here camp chairs", "grp6", (r) => replies.push(r)), true);
+ await tick();
+ setAppThreadResolver(async () => "todoThread"); // restore for later tests
+ assert.equal(sent.length, 2);
+ assert.deepEqual([sent[0].threadId, sent[0].event.type, sent[0].event.app], ["grp6", "groupapps.add", "todo"]);
+ assert.deepEqual([sent[1].threadId, sent[1].event.type, sent[1].event.text], ["grp6", "todo.add", "camp chairs"]);
+ // In-thread the install + add action lines are the record — no extra reply.
+ assert.deepEqual(replies, []);
+ // A bare "/todo @here" is JUST the install; already-added → no duplicate add.
+ foldEvent("grp6", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ const sent2 = [];
+ setEmitTransport((threadId, event) => (sent2.push(event), Promise.resolve(true)));
+ assert.equal(runCommand("/todo @here", "grp6", (r) => replies.push(r)), true);
+ await tick();
+ assert.deepEqual(sent2, [], "already added + no text → nothing to emit");
+ setThreadIsGroup(null);
+ resetThread("grp6");
+});
+
+await test("@here outside a group (or for an unshareable app) explains instead of mis-filing", async () => {
+ setThreadIsGroup(() => false);
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true)));
+ assert.equal(runCommand("/todo @here buy milk", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(sent.length, 0, "nothing emitted — the text is not silently filed anywhere");
+ assert.match(replies[0].text, /only works in a group chat/);
+ setThreadIsGroup(null);
+});
+
+await test("the / catalog says where a group-capable command lands", () => {
+ resetThread("grp7");
+ setThreadIsGroup((id) => id === "grp7");
+ const desc = (list) => list.find((c) => c.name === "/todo").desc;
+ assert.match(desc(commandCatalog("grp7")), /yours — @here to share$/);
+ foldEvent("grp7", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ assert.match(desc(commandCatalog("grp7")), /shared in this chat$/);
+ // Outside a group the hint would be noise — the plain desc stands.
+ assert.equal(desc(commandCatalog("chatX")), "Add a to-do");
+ setThreadIsGroup(null);
+ resetThread("grp7");
+});
+
+await test("runAppMention: a bare mention gets the usage reply; plain text never matches", async () => {
+ resetThread("grp4");
+ foldEvent("grp4", { type: "groupapps.add", app: "todo", name: "Ana" }, { id: 1 });
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true)));
+ assert.equal(runAppMention("@To-do", "grp4", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(sent.length, 0, "nothing to add");
+ assert.equal(replies.length, 1, "the usage line still shows (it's content, not a confirmation)");
+ assert.equal(replies[0].sender.name, "To-do");
+ assert.equal(replies[0].text, "Usage: @To-do <task>", "usage speaks in mention form, not a slash verb");
+ // Ordinary conversation sails past — an app must never eat a group's messages.
+ assert.equal(runAppMention("who's around for lunch?", "grp4", (r) => replies.push(r)), false);
+ assert.equal(runAppMention("@Ana can you look?", "grp4", (r) => replies.push(r)), false);
+ resetThread("grp4");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index a8a7861..aa55267 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -116,6 +116,16 @@ function testRemindDoneFold(state, event) {
return { items: removeItem(state.items, event.id) };
}
+// Group-apps registry fold handlers (web/static/apps.js — the groupapps app)
+function testGroupAppsAddFold(state, event) {
+ if (typeof event.app !== "string" || !event.app) return state;
+ return { apps: addItem(state.apps, { id: event.app, by: event.name || "", ts: event.ts || 0 }) };
+}
+
+function testGroupAppsRemoveFold(state, event) {
+ return { apps: removeItem(state.apps, event.app) };
+}
+
// Test each vector
let passes = 0;
let fails = 0;
@@ -341,4 +351,13 @@ allVectors.remind.forEach((vector) => {
}
});
+// Test group-apps registry vectors (which apps a group chat has added)
+allVectors.groupapps.forEach((vector) => {
+ test(`groupapps: ${vector.name} — folds to the expected registry state`, () => {
+ const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] };
+ const result = vector.event.type === "groupapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testGroupAppsAddFold(state, vector.event);
+ assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly");
+ });
+});
+
if (fails) process.exit(1);
diff --git a/test/event-vectors/groupapps.json b/test/event-vectors/groupapps.json
new file mode 100644
index 0000000..817e0c1
--- /dev/null
+++ b/test/event-vectors/groupapps.json
@@ -0,0 +1,123 @@
+{
+ "description": "Group-apps registry vectors: which trusted first-party apps a group chat has added. The registry is fold state from wire events in the group's own encrypted log — the server never learns which app was added. Source of truth: web/static/apps.js (the groupapps system app).",
+ "app": "groupapps",
+ "source": "web/static/apps.js",
+ "state_shape": { "apps": [{ "id": "string (the added app's name, e.g. \"todo\")", "by": "string (sender name)", "ts": "number (epoch ms)" }] },
+ "notes": [
+ "The item id IS the app name, so a duplicate add (any member, any refold) is a no-op and a remove needs no lookup.",
+ "Unknown app names fold into state unchanged in shape — clients filter to registered, groupCapable apps at READ time (groupAppsFor), so old and new clients converge on the same log.",
+ "Chat projection: the group fold prefixes the actor's name onto the projection text, so the rendered action line is e.g. \"Ana added To-do to this chat\". The expected_chat_projection below is the raw chatMessage() output, pre-prefix — same convention as todo.json.",
+ "An app's own events posted into a group thread fold with the app's ordinary vectors (todo.json etc.) — same events, same reducer, keyed by the group thread instead of the personal one."
+ ],
+ "vectors": [
+ {
+ "name": "groupapps_add",
+ "description": "A member adds a trusted app to the group.",
+ "event": {
+ "type": "groupapps.add",
+ "app": "todo",
+ "name": "Ana",
+ "ts": 1700000000000
+ },
+ "expected_state_update": {
+ "apps": [
+ {
+ "id": "todo",
+ "by": "Ana",
+ "ts": 1700000000000
+ }
+ ]
+ },
+ "expected_chat_projection": {
+ "text": "added To-do to this chat",
+ "action": true
+ }
+ },
+ {
+ "name": "groupapps_add_duplicate",
+ "description": "Re-adding an already-added app is a no-op (idempotent by app name).",
+ "event": {
+ "type": "groupapps.add",
+ "app": "todo",
+ "name": "Bob",
+ "ts": 1700000005000
+ },
+ "initial_state": {
+ "apps": [
+ {
+ "id": "todo",
+ "by": "Ana",
+ "ts": 1700000000000
+ }
+ ]
+ },
+ "expected_state_update": {
+ "apps": [
+ {
+ "id": "todo",
+ "by": "Ana",
+ "ts": 1700000000000
+ }
+ ]
+ },
+ "note": "The original add's attribution is kept — a duplicate never rewrites by/ts."
+ },
+ {
+ "name": "groupapps_remove",
+ "description": "A member removes an added app from the group.",
+ "event": {
+ "type": "groupapps.remove",
+ "app": "todo",
+ "name": "Ana",
+ "ts": 1700000010000
+ },
+ "initial_state": {
+ "apps": [
+ {
+ "id": "todo",
+ "by": "Ana",
+ "ts": 1700000000000
+ }
+ ]
+ },
+ "expected_state_update": {
+ "apps": []
+ },
+ "expected_chat_projection": {
+ "text": "removed To-do from this chat",
+ "action": true
+ }
+ },
+ {
+ "name": "groupapps_add_malformed",
+ "description": "An add with no app name (malformed or hostile) folds to a no-op, never a crash.",
+ "event": {
+ "type": "groupapps.add",
+ "name": "Mallory",
+ "ts": 1700000020000
+ },
+ "initial_state": {
+ "apps": []
+ },
+ "expected_state_update": {
+ "apps": []
+ }
+ },
+ {
+ "name": "groupapps_remove_missing",
+ "description": "Removing an app that isn't added (a cancel racing a remove, a refold) is a no-op.",
+ "event": {
+ "type": "groupapps.remove",
+ "app": "todo",
+ "name": "Ana",
+ "ts": 1700000030000
+ },
+ "initial_state": {
+ "apps": []
+ },
+ "expected_state_update": {
+ "apps": []
+ }
+ }
+ ]
+}
diff --git a/test/event-vectors/index.js b/test/event-vectors/index.js
index 9764d0c..eaf3f4d 100644
--- a/test/event-vectors/index.js
+++ b/test/event-vectors/index.js
@@ -7,10 +7,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export const chatVectors = JSON.parse(readFileSync(join(__dirname, "chat.json"), "utf8"));
export const todoVectors = JSON.parse(readFileSync(join(__dirname, "todo.json"), "utf8"));
export const remindVectors = JSON.parse(readFileSync(join(__dirname, "remind.json"), "utf8"));
+export const groupAppsVectors = JSON.parse(readFileSync(join(__dirname, "groupapps.json"), "utf8"));
// Flatten all vectors for easy iteration in tests
export const allVectors = {
chat: chatVectors.vectors || [],
todo: todoVectors.vectors || [],
remind: remindVectors.vectors || [],
+ groupapps: groupAppsVectors.vectors || [],
};
diff --git a/web/pages/index.html b/web/pages/index.html
index 46393ee..84772f6 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -132,6 +132,7 @@
<button class="menu-item" id="thread-add" role="menuitem">Add people</button>
<button class="menu-item" id="thread-edit" role="menuitem">Edit group</button>
<button class="menu-item" id="thread-members" role="menuitem">View members</button>
+ <button class="menu-item" id="thread-apps" role="menuitem">Add apps</button>
<button class="menu-item" id="thread-pin" role="menuitem">Pin chat</button>
<button class="menu-item" id="thread-close" role="menuitem">Close chat</button>
<button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button>
diff --git a/web/static/apps.js b/web/static/apps.js
index 1f64eb4..0318f23 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -290,12 +290,82 @@ export function appChatOnly(name) {
return !!(app && typeof app.chatMessage === "function" && app.chatOnly);
}
+// ---- group-scoped apps ----------------------------------------------------
+//
+// A `groupCapable: true` personal app (To-do) can ALSO be added to a group
+// chat, where ONE shared instance runs for everyone: its events post into the
+// GROUP thread's encrypted log, so every member folds the same state — the same
+// events, the same reducer, a different thread. Nothing else changes: the fold,
+// snapshots, scheduling, and live delivery are already per-thread.
+//
+// Which apps a group has added is itself fold state, from wire events in the
+// group's own log — so it syncs to every member, survives via snapshots, and
+// the server never learns which app was added (or that one was). Any member can
+// add or remove one, like adding people; both acts show as action lines.
+export const groupAppsApp = defineApp({
+ name: "groupapps",
+ title: "Apps",
+ glyph: "▦",
+ scope: "system", // a registry, not a surface — never listed, never given a panel
+ initialState: () => ({ apps: [] }),
+ events: {
+ // The item id IS the app name, so re-adding is idempotent (addItem dedupes)
+ // and a remove needs no lookup. Unknown app names fold fine — they just
+ // don't surface until a client that knows the app reads the state (see
+ // groupAppsFor), so old and new clients converge on the same log.
+ "groupapps.add": (s, e) =>
+ typeof e.app === "string" && e.app ? { apps: addItem(s.apps, { id: e.app, by: e.name || "", ts: e.ts || 0 }) } : s,
+ "groupapps.remove": (s, e) => ({ apps: removeItem(s.apps, e.app) }),
+ },
+ // Adding or removing an app is a visible act in the conversation, like a
+ // rename — the group fold prefixes the actor ("Ciara added To-do to this chat").
+ chatMessage(e) {
+ const title = (appByName(e.app) || {}).title || e.app || "an app";
+ if (e.type === "groupapps.add") return { text: `added ${title} to this chat`, action: true };
+ if (e.type === "groupapps.remove") return { text: `removed ${title} from this chat`, action: true };
+ return null;
+ },
+ // The sidebar line for a chat where an app was just added/removed.
+ preview(e) {
+ const title = (appByName(e.app) || {}).title || e.app || "an app";
+ if (e.type === "groupapps.add") return `${e.name || "Someone"} added ${title}`;
+ if (e.type === "groupapps.remove") return `${e.name || "Someone"} removed ${title}`;
+ return null;
+ },
+});
+
+// The apps a user could add to a group — feeds the "Add apps" picker.
+export function groupCapableApps() {
+ return apps.filter((a) => a.groupCapable && a.render);
+}
+
+// The apps ADDED to a group thread: the thread's folded groupapps state,
+// resolved against the registry and filtered to apps that are actually
+// group-capable — a hostile or stale add naming anything else stays inert.
+export function groupAppsFor(threadId) {
+ const s = appStateFor(threadId, "groupapps");
+ if (!s || !Array.isArray(s.apps)) return [];
+ const out = [];
+ for (const it of s.apps) {
+ const a = it && appByName(it.id);
+ if (a && a.groupCapable && a.render) out.push(a);
+ }
+ return out;
+}
+
// isAppType reports whether some app owns this payload type — i.e. this message
// is an app event to fold, not a chat bubble to render.
export function isAppType(type) {
return type != null && typeOwner.has(type);
}
+// appForType returns the app owning a wire event type, or null — for a host
+// that already holds a decrypted event and needs its app (e.g. to project it
+// into a group conversation) without re-deriving ownership itself.
+export function appForType(type) {
+ return (type != null && typeOwner.get(type)) || null;
+}
+
// ---- per-thread state: threadId → (appName → reducerState) --------------
const store = new Map();
@@ -611,6 +681,14 @@ let threadHasBotFn = null;
export function setThreadHasBot(fn) {
threadHasBotFn = fn;
}
+// Whether a thread is a GROUP chat — the surface that can host shared app
+// instances ("@here", the Add-apps sheet). shell.js injects the kind lookup;
+// absent (Node tests inject a stub, native embeds may not) → nothing is a group,
+// so "@here" degrades to an explanatory reply, never a mis-filed event.
+let threadIsGroupFn = null;
+export function setThreadIsGroup(fn) {
+ threadIsGroupFn = fn;
+}
// Which app OWNS a thread (a kind=="self" app-thread's app name, else null) —
// shell.js injects its threadApp lookup. This is what scopes local commands and
// the default command to the app's own thread. Absent → no thread is app-owned.
@@ -840,6 +918,83 @@ export function defaultCommandFor(threadId) {
return { app, name: app.defaultCommand, spec };
}
+// ---- @-mentioning a group app --------------------------------------------
+//
+// In a chat that has apps added, "@To-do buy milk" runs the app's DEFAULT
+// command with the rest of the text: the mention is the address, the text is
+// the action, and what lands in the thread is the EVENT (folded by every
+// member) — not a message. The same "the message becomes the action" idea as
+// defaultCommand in a personal app-thread, made explicit because a group's
+// plain text is real conversation that an app must never eat (see CLAUDE.md).
+
+// parseAppMention pulls the addressed app out of leading-@ text —
+// "@To-do buy milk" → { app, args: "buy milk" } — or null when the text doesn't
+// address an added app. Matches an ADDED app (this thread's groupapps state) by
+// title or name, case-insensitively, and requires the mention to break on
+// whitespace so "@To-dos…" never matches To-do. Pure, so it's testable in Node.
+export function parseAppMention(text, threadId) {
+ if (!text || text[0] !== "@") return null;
+ for (const app of groupAppsFor(threadId)) {
+ if (!app.defaultCommand) continue;
+ for (const label of [app.title, app.name]) {
+ if (!label) continue;
+ if (text.slice(1, 1 + label.length).toLowerCase() !== label.toLowerCase()) continue;
+ const rest = text.slice(1 + label.length);
+ if (rest && !/^\s/.test(rest)) break; // mid-word — not this app's mention
+ return { app, args: rest.trim() };
+ }
+ }
+ return null;
+}
+
+// runAppMention executes "@App <text>" typed in `threadId`: the app's default
+// command runs with ctx bound to THAT thread, so the event lands in the group
+// log. Returns true when the text addressed an added app — the composer then
+// clears instead of sending a message. Confirmation replies are suppressed
+// (the folded event already shows as an action line — the signal is `short`,
+// see noticeCalmsDown); content replies (a usage line for a bare "@To-do")
+// still render privately where you typed, via respond.
+export function runAppMention(text, threadId, respond) {
+ const m = parseAppMention(text, threadId);
+ if (!m) return false;
+ const spec = m.app.commands[m.app.defaultCommand];
+ if (!spec || !spec.run) return false;
+ const label = m.app.title || m.app.name;
+ const sender = { name: label, glyph: m.app.glyph, kind: "app" };
+ const reply = (r) => {
+ try {
+ respond && respond({ sender, ...r });
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ // A bare mention of a command that expects arguments gets the usage line in
+ // MENTION form — the command's own usage would name a slash verb (/add) that
+ // doesn't resolve in this thread.
+ if (!m.args && spec.args) {
+ reply({ text: `Usage: @${label} ${spec.args}` });
+ return true;
+ }
+ const ctx = makeCtx(threadId, m.app);
+ ctx.reply = (r) => {
+ if (noticeCalmsDown(r)) return; // the action line in the feed is the record
+ reply(r);
+ };
+ Promise.resolve(spec.run(m.args, ctx)).catch((e) => console.error(e));
+ return true;
+}
+
+// parseHere pulls the "@here" flag off a command's arguments — the general
+// pattern "/todo @here buy milk": the flag addresses THIS chat's shared
+// instance, installing the app first if it isn't added yet (see runCommand).
+// Only a LEADING "@here" counts, and it must break on whitespace ("@heresy"
+// is just text). Returns { rest } with the remaining args, or null when the
+// flag isn't present. Pure, so it's testable in Node.
+export function parseHere(args) {
+ const m = /^@here(?:\s+([\s\S]*))?$/.exec((args || "").trim());
+ return m ? { rest: (m[1] || "").trim() } : null;
+}
+
// /me is unlike every other command: it BROADCASTS a real message (an
// action-style line, `{text, action:true}`) rather than answering privately,
// so the composer (chat.js) handles it directly, before runCommand ever sees
@@ -895,10 +1050,47 @@ export function runCommand(text, activeThreadId, respond) {
const app = cmd.app;
(async () => {
let target = activeThreadId;
- // A LOCAL command only exists in the thread it was typed in — it targets that
- // thread directly, no routing. A global personal command routes to its own
- // app-thread from anywhere.
- if (!cmd.local && app.scope === "personal") target = resolveAppThread ? await resolveAppThread(app.name) : null;
+ let args = cmd.args;
+ const sender = { name: app.title || app.name, glyph: app.glyph, kind: "app" };
+ // "/todo @here buy milk" — the @here flag addresses THIS chat's shared
+ // instance, INSTALLING the app into the group first if needed (the same
+ // groupapps.add the Add-apps sheet emits, so everyone gets the widget bar
+ // 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);
+ if (here) {
+ args = here.rest;
+ if (!app.groupCapable) {
+ reply({ sender, text: `${app.title || app.name} can't be shared to a chat.` });
+ return;
+ }
+ if (!(threadIsGroupFn && threadIsGroupFn(activeThreadId))) {
+ reply({ sender, text: `@here shares an app with a group — it only works in a group chat.` });
+ return;
+ }
+ if (!groupAppsFor(activeThreadId).some((a) => a.name === app.name)) {
+ const sent = await emitAppEvent(activeThreadId, { type: "groupapps.add", app: app.name });
+ if (!sent) {
+ reply({ sender, text: `Couldn't add ${app.title || app.name} — check your connection and try again.` });
+ return;
+ }
+ }
+ if (!args) return; // just the install — the "added … to this chat" action line is the record
+ // target stays THIS chat: the event goes to the shared instance.
+ } else {
+ // A LOCAL command only exists in the thread it was typed in — it targets
+ // that thread directly, no routing. A global personal command routes to
+ // its own app-thread from anywhere — UNLESS the app is ADDED to the chat
+ // you're typing in (groupAppsFor): the group's shared instance is the
+ // nearer scope, so "/todo" in that chat files into the GROUP list, exactly
+ // 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) {
+ target = resolveAppThread ? await resolveAppThread(app.name) : null;
+ }
+ }
if (!target) return;
const ctx = makeCtx(target, app);
// ctx.reply is the private "only visible to you" confirmation, rendered where
@@ -907,9 +1099,9 @@ export function runCommand(text, activeThreadId, respond) {
// reply would be redundant. From another chat, it confirms where the action went.
ctx.reply = (r) => {
if (target === activeThreadId) return;
- reply({ sender: { name: app.title || app.name, glyph: app.glyph, kind: "app" }, ...r });
+ reply({ sender, ...r });
};
- await app.commands[cmd.name].run(cmd.args, ctx);
+ await app.commands[cmd.name].run(args, ctx);
})();
return true;
}
@@ -931,9 +1123,16 @@ export function commandCatalog(threadId) {
}
for (const [name, app] of commandOwner.entries()) {
if (owner && app === owner) continue; // its global aliases are noise in its own thread
+ let desc = (app.commands[name] && app.commands[name].desc) || "";
+ // 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)) {
+ desc += groupAppsFor(threadId).some((a) => a.name === app.name) ? " · shared in this chat" : " · yours — @here to share";
+ }
out.push({
name,
- desc: (app.commands[name] && app.commands[name].desc) || "",
+ desc,
args: (app.commands[name] && app.commands[name].args) || "",
bot: false,
});
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index be08460..7a9c16f 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -120,6 +120,7 @@ That's a whole app. Everything below is reference detail and the optional hooks.
| `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". |
| `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. |
@@ -158,6 +159,74 @@ 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`
§5 for why, and the design question to revisit once the classic feed is retired.
+### Group-scoped apps: the same app, personal AND shared
+
+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
+group — at the same time as everyone's personal copies. This is not a new
+model; it's the existing one pointed at a different thread:
+
+- **Same events, same reducer, different log.** The group's instance folds the
+ app's ordinary events (`todo.add`, …) from the *group thread's* encrypted
+ log instead of your personal app-thread's. Every member folds the same
+ events, so everyone sees the same list; snapshots, scheduling, and live
+ delivery already work per-thread and need nothing new.
+- **Which apps a group has added is itself fold state** — the framework's
+ `groupapps` registry app owns `groupapps.add` / `groupapps.remove` wire
+ events (`{ app: "todo" }`), folded per thread like any other. It syncs to
+ every member and the server never learns which app was added (or that one
+ was). Any member can add or remove, like adding people; both acts render as
+ action lines ("Ana added To-do to this chat").
+- **The widget bar.** A group with apps added gets the header view toggle:
+ Chat | Split (desktop) | one glyph button per added app. Tapping an app's
+ button shows it beside the chat; a second tap gives it the full pane (on
+ mobile, where there's no split, the first tap does); tapping a different
+ app's button swaps the pane in place. The app renders in the same Interface
+ pane a companion app-thread uses, with `ctx` bound to the group thread —
+ `ctx.emit` lands in the group log. Nothing about your `render` changes.
+- **Chat projection is attributed.** In a group, a `chatMessage` ACTION
+ projection leads with the actor's name ("Ciara added “buy milk”" — a group
+ has many hands); a MESSAGE projection (a reminder firing) stays a bubble in
+ the app's voice. Write `chatMessage` exactly as for the companion view; the
+ host adds the prefix. In a group it's called with `{ group: true }` as a
+ second argument, for wording that must differ when many people read it
+ (Reminders drops "you asked me" there) — apps that don't care ignore it.
+- **@-mention runs the default command.** "@To-do buy milk" typed in the
+ conversation runs the app's `defaultCommand` with the rest of the text — the
+ event is what lands, shown as an action line, and the app joins the `@`
+ typeahead. This is the *explicit* form of defaultCommand: a group's plain
+ text is real conversation, so a group app must never eat it implicitly
+ (`defaultCommand`'s automatic form stays personal-thread-only).
+- **Slash commands scope to the nearest instance.** In a chat that has the app
+ added, its global command acts on the GROUP instance — "/todo buy milk"
+ there files into the shared list, exactly like "@To-do" (see `runCommand`).
+ Everywhere else it keeps routing to your personal app-thread, so the same
+ verb always addresses the instance you're looking at.
+- **`@here` installs and shares in one gesture.** In a group *without* the
+ app, "/todo @here buy milk" adds the app to the group (the same
+ `groupapps.add` the sheet emits, so everyone gets the widget bar and the
+ action line) and files the item into the new shared instance; a bare
+ "/todo @here" just installs. Anywhere the flag can't apply — not a group,
+ or an app that can't be shared — it answers with a private explanation
+ instead of mis-filing the text. The "/" typeahead makes all of this
+ discoverable: a group-capable command's description says where it will
+ land — "shared in this chat" when the app is added, "yours — @here to
+ share" when it isn't.
+
+What `groupCapable` asks of your app: nothing beyond the standard invariants —
+a pure, idempotent, validating reducer (strangers' events are now first-class
+inputs), a `render` that works off `ctx.threadId` (never a stashed thread id),
+and per-user logic keyed on `e.uid`. If your app satisfies those, the flag is
+the whole opt-in. Vectors: `test/event-vectors/groupapps.json`.
+
+Currently flagged: To-do, Reminders, Bookmarks. Deliberately NOT flagged:
+apps whose state derives from one member's private service credentials
+(Integrations, DNSimple) — sharing those needs its own design (whose
+credentials fetch? who may see the result?), so the per-app gate is the point,
+not a limitation. Poll and Whiteboard predate this and stay `scope: "shared"`
+(the always-available panel above the feed); folding them into the added-apps
+widget bar is a candidate follow-up.
+
### Local & default commands
A command is **global** by default: it's in the `/` typeahead of every thread,
diff --git a/web/static/apps/bookmarks.js b/web/static/apps/bookmarks.js
index 321f8b8..42e8c90 100644
--- a/web/static/apps/bookmarks.js
+++ b/web/static/apps/bookmarks.js
@@ -48,6 +48,11 @@ export const app = defineApp({
blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native)
glyph: "🔖",
scope: "personal",
+ // Also addable to a group chat (⋮ → Add apps): a shared link list for the
+ // group, folded from the group thread's log; /save there scopes to the group
+ // instance. (The "Save link" message action still files into YOUR bookmarks —
+ // saving somebody's link privately is the point of that gesture.)
+ groupCapable: true,
initialState: () => ({ items: [] }),
events: {
"bookmark.add": (s, e) => {
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index a93ad87..a87677d 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -22,7 +22,7 @@
//
// This is the DEFAULT chat experience for group/DM threads; a device can
// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
-import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor } from "../apps.js";
+import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor } from "../apps.js";
import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { emojify } from "../emoji.js";
import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js";
@@ -2752,7 +2752,14 @@ function buildSkeleton(el, ctx) {
c.textContent = ":" + it.code + ":";
row.append(g, c);
} else {
- if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
+ // A group-added app in the @ typeahead wears its glyph avatar (there's
+ // no roster photo to load); people keep the original avatar element.
+ if (it.glyph) {
+ const g = document.createElement("span");
+ g.className = "avatar app-avatar";
+ g.textContent = it.glyph;
+ row.appendChild(g);
+ } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
const n = document.createElement("span");
n.className = "ta-name";
n.textContent = it.name;
@@ -2939,6 +2946,14 @@ function buildSkeleton(el, ctx) {
resetInput();
return;
}
+ // "@To-do buy milk" in a chat with apps added: the mention addresses the
+ // app, the text runs its default command — an EVENT into this thread (shown
+ // as an action line), not a message. Leading-@ only; a mention mid-text is
+ // ordinary conversation. See runAppMention.
+ if (!editing && !replyTo && !pending.length && text && text[0] === "@" && runAppMention(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
+ resetInput();
+ return;
+ }
// Plain text in a default-command thread runs the app's default command
// instead of sending a message nobody reads — typing "buy milk" into To-do
// just adds it (defaultCommandFor gates this to app-threads with no bot;
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 8864f23..a330ed8 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -177,6 +177,11 @@ export const app = defineApp({
blurb: "Set a reminder", // sidebar subtitle before first use (sync w/ native)
glyph: "⏰",
scope: "personal",
+ // Also addable to a group chat (⋮ → Add apps): shared reminders for the whole
+ // group — the server delivers remind.fire into the GROUP thread, so every
+ // member folds it, sees the bubble, and gets the push. Same events, same
+ // reducer, a different thread; /remind there scopes to the group instance.
+ groupCapable: true,
initialState: () => ({ items: [] }),
events: {
"remind.add": (s, e) => {
@@ -211,9 +216,14 @@ export const app = defineApp({
chatIntro:
"This is the start of your chat history with Reminders. Type /remind me in 10 minutes to Something — e.g. /remind me in 10 minutes to call mum. You can see and manage your reminders in the Interface view.",
- chatMessage(e) {
- // Firing is the bot messaging YOU (a bubble); setting one is an action you took.
- if (e.type === "remind.fire") return `You asked me to remind you: “${(e.notice && e.notice.body) || ""}”`;
+ chatMessage(e, opts) {
+ // Firing is the bot messaging YOU (a bubble); setting one is an action you
+ // took. In a GROUP the bubble speaks to everyone — the reminder may have
+ // been set by another member, so "you asked me" would be wrong there.
+ if (e.type === "remind.fire") {
+ const body = (e.notice && e.notice.body) || "";
+ return opts && opts.group ? `Reminder: “${body}”` : `You asked me to remind you: “${body}”`;
+ }
if (e.type === "remind.add") {
const at = absTime(e.when);
return { text: `set a reminder: “${e.text}”${at ? " — " + at : ""}`, action: true };
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index da434a5..80bd3ce 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -29,6 +29,11 @@ export const app = defineApp({
blurb: "Create a to-do list", // sidebar subtitle before first use (sync w/ native)
glyph: "✓",
scope: "personal",
+ // Also addable to a group chat (⋮ → Add apps): one SHARED list for the whole
+ // group — the same events and reducer, folded from the group thread's log
+ // instead of your personal one. "@To-do buy milk" in the conversation adds
+ // one (the default command, addressed by mention — see runAppMention).
+ groupCapable: true,
initialState: () => ({ items: [] }),
events: {
"todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, done: false, doneAt: 0, by: e.name || "" }) } : s),
@@ -59,12 +64,22 @@ export const app = defineApp({
// Adding a to-do reads as an action in the chat history (a /me-style line);
// done/reopen/remove happen in the Interface view and carry no text, so they
- // don't clutter the chat.
+ // don't clutter the chat. In a group the host prefixes the actor's name.
chatMessage(e) {
if (e.type === "todo.add") return { text: `added “${e.text}”`, action: true };
return null;
},
+ // Sidebar presence: a new to-do previews in the chat list and lights the
+ // unread dot — how a group notices shared-list activity without opening the
+ // thread. Your own adds never light the dot (the host's ownMessage gate), and
+ // ticks/removals stay quiet everywhere.
+ preview(e) {
+ if (e.type === "todo.add" && e.text) return `✓ ${e.name || "Someone"} added “${e.text}”`;
+ return null;
+ },
+ unread: (e) => e.type === "todo.add",
+
// The empty-state intro shown the first time you open this bot's chat.
chatIntro:
"This is the start of your chat history with To-do. Just type something to add it as a to-do — e.g. buy milk. You can see, add, and check off to-dos in the Interface view.",
diff --git a/web/static/shell.js b/web/static/shell.js
index 5bc17cd..422b928 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -50,6 +50,7 @@ import {
setToast,
setDefaultBot,
setThreadHasBot,
+ setThreadIsGroup,
setThreadOwner,
setThreadOpener,
setHostBridge,
@@ -72,9 +73,13 @@ import {
renderSurfaces,
renderApp as renderMiniApp,
appByName,
+ appForType,
appWantsChatView,
appChatOnly,
personalApps,
+ groupCapableApps,
+ groupAppsFor,
+ emitAppEvent,
messageActionsFor,
renderInline as renderAppLine,
} from "/static/apps.js";
@@ -461,6 +466,9 @@ const threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thre
// views (custom, per-thread) are planned — a view dropdown, not just this toggle.
// Sticks per thread for the session. Only meaningful when threadApp[id] is set.
const appThreadView = {}; // thread id -> "chat" | "interface"
+// For a GROUP chat with apps added (see groupAppsFor): which added app its
+// Interface pane shows. Sticks per thread for the session, like appThreadView.
+const groupAppView = {}; // thread id -> app name
const threadPreview = {}; // thread id -> last-message snippet
// Pending-invite links, keyed by the (pending DM) thread id. Only known in the
// session that minted them — the secret never reaches the server, so it can't be
@@ -1611,6 +1619,10 @@ function renderApp() {
closeTMenu();
showMembers();
};
+ document.getElementById("thread-apps").onclick = () => {
+ closeTMenu();
+ manageGroupApps();
+ };
document.getElementById("thread-pin").onclick = () => {
closeTMenu();
togglePin();
@@ -2068,9 +2080,9 @@ function paintThreadHeader(id) {
sub.onclick = isGroup ? () => showMembers() : null; // tap "N members" → member list
sub.style.cursor = isGroup ? "pointer" : "";
// The kebab is always available (every chat can be closed); the group-only
- // items (add / edit / members / leave) show only for groups.
+ // items (add / edit / members / apps / leave) show only for groups.
document.getElementById("thread-menu-wrap").style.display = "";
- for (const gid of ["thread-add", "thread-edit", "thread-members", "thread-leave"]) {
+ for (const gid of ["thread-add", "thread-edit", "thread-members", "thread-apps", "thread-leave"]) {
const el = document.getElementById(gid);
if (el) el.style.display = isGroup ? "" : "none";
}
@@ -2079,10 +2091,13 @@ function paintThreadHeader(id) {
paintHeaderAvatar(id);
}
-// The [Chat | Split | Interface] view toggle sits beside the kebab, but ONLY for
-// app-threads. Chat = the conversation; Split = both side by side (desktop only —
-// there's no room to split on mobile); Interface = the app's interactive UI (its
-// glyph, ⏰ for Reminders). Seed of a fuller view picker (a dropdown) later.
+// The [Chat | Split | Interface] view toggle sits beside the kebab, for two
+// kinds of thread. A COMPANION app-thread gets Chat = the conversation; Split =
+// both side by side (desktop only — there's no room to split on mobile);
+// Interface = the app's interactive UI (its glyph, ⏰ for Reminders). A GROUP
+// chat with apps added (⋮ → Add apps) gets the same bar with one button per
+// added app — the shared widget bar every member sees. Seed of a fuller view
+// picker (a dropdown) later.
function paintViewToggle(id) {
const head = document.querySelector(".thread-head");
const menuWrap = document.getElementById("thread-menu-wrap");
@@ -2091,7 +2106,9 @@ function paintViewToggle(id) {
// Only a companion app-thread has more than one view to switch between; a
// takeover app (no chatMessage) owns the whole surface, and a chat-only app
// (the setup conversation) has just its chat — neither gets a toggle.
- if (!owner || !head || !appWantsChatView(owner) || appChatOnly(owner)) {
+ const companion = !!owner && appWantsChatView(owner) && !appChatOnly(owner);
+ const groupApps = !owner && chatAppFor(id) ? groupAppsFor(id) : [];
+ if (!head || (!companion && !groupApps.length)) {
if (toggle) toggle.remove();
return;
}
@@ -2103,35 +2120,105 @@ function paintViewToggle(id) {
toggle.setAttribute("aria-label", "View");
head.insertBefore(toggle, menuWrap);
}
- const view = appThreadView[id] || "split";
- const glyph = (appByName(owner) || {}).glyph || "▦";
+ const view = appThreadView[id] || (companion ? "split" : "chat");
// On a narrow layout there's no split — it collapses to Chat, so Chat reads as
// active and the (desktop-only) Split button is hidden.
const narrow = matchMedia("(max-width: 720px)").matches;
const active = narrow && view === "split" ? "chat" : view;
toggle.textContent = "";
- const mk = (v, label, sym, splitOnly) => {
+ const mk = (label, sym, isActive, splitOnly, onTap) => {
const b = document.createElement("button");
b.type = "button";
- b.className = "view-toggle-btn" + (v === active ? " active" : "") + (splitOnly ? " split-only" : "");
+ b.className = "view-toggle-btn" + (isActive ? " active" : "") + (splitOnly ? " split-only" : "");
b.title = label;
b.setAttribute("aria-label", label);
- b.setAttribute("aria-pressed", String(v === active));
+ b.setAttribute("aria-pressed", String(isActive));
b.textContent = sym;
- b.onclick = () => setAppThreadView(id, v);
+ b.onclick = onTap;
return b;
};
- toggle.append(mk("chat", "Chat", "💬", false), mk("split", "Split", "◫", true), mk("interface", "Interface", glyph, false));
+ toggle.append(
+ mk("Chat", "💬", active === "chat", false, () => setAppThreadView(id, "chat")),
+ mk("Split", "◫", active === "split", true, () => setAppThreadView(id, "split")),
+ );
+ if (companion) {
+ const glyph = (appByName(owner) || {}).glyph || "▦";
+ toggle.append(mk("Interface", glyph, active === "interface", false, () => setAppThreadView(id, "interface")));
+ return;
+ }
+ // Group chat: one button per added app. Tapping one shows THAT app — beside
+ // the chat first (desktop), and a SECOND tap gives it the full pane; mobile
+ // has no split, so it goes straight to full-pane. Tapping a DIFFERENT app's
+ // button keeps the current view and just swaps which app the pane shows.
+ const sel = groupAppSelection(id);
+ for (const a of groupApps) {
+ toggle.append(
+ mk(a.title || a.name, a.glyph || "▦", active === "interface" && sel === a.name, false, () => {
+ const cur = appThreadView[id] || "chat";
+ const wasSel = groupAppSelection(id);
+ groupAppView[id] = a.name;
+ if (cur === "chat") setAppThreadView(id, narrow ? "interface" : "split");
+ else if (cur === "split" && (narrow || wasSel === a.name)) setAppThreadView(id, "interface");
+ else if (cur === "interface" && wasSel === a.name) return; // already the active segment
+ else {
+ // Same view, different app — swap the pane in place.
+ renderGroupAppPane(id);
+ paintViewToggle(id);
+ }
+ }),
+ );
+ }
}
-// Switch an app-thread's view (chat | split | interface). Both panes are already
-// mounted, so this only flips the layout — instant, no re-render or re-fetch.
+// Switch an app-thread's (or group-with-apps chat's) view (chat | split |
+// interface). Both panes are already mounted, so this only flips the layout —
+// instant, no re-render or re-fetch (a group's app pane re-renders from
+// already-folded state).
function setAppThreadView(id, view) {
- const cur = appThreadView[id] || "split";
- if (cur === view || !threadApp[id]) return;
+ const cur = appThreadView[id] || (threadApp[id] ? "split" : "chat");
+ if (cur === view) return;
+ if (!threadApp[id] && !groupAppsFor(id).length) return;
appThreadView[id] = view;
applyAppThreadView(id);
+ renderGroupAppPane(id);
+ paintViewToggle(id);
+}
+
+// The added app a group chat's Interface pane shows: the sticky per-session
+// choice while that app is still added, else the first added app (or null).
+function groupAppSelection(id) {
+ const added = groupAppsFor(id);
+ if (!added.length) return null;
+ const pick = groupAppView[id];
+ return added.some((a) => a.name === pick) ? pick : added[0].name;
+}
+
+// Render a group chat's selected added app into the Interface pane — the same
+// pane a companion app-thread uses, so the split CSS applies unchanged. ctx is
+// bound to the GROUP thread: the app's emits land in the group's encrypted log,
+// folded by every member. Cleared in the chat view so #interface-pane:empty
+// collapses it and the conversation takes the full width.
+function renderGroupAppPane(id) {
+ if (threadApp[id]) return; // a companion app-thread's pane isn't ours to touch
+ const iface = document.getElementById("interface-pane");
+ if (!iface || id !== activeId) return;
+ const sel = groupAppSelection(id);
+ if (!sel || (appThreadView[id] || "chat") === "chat") {
+ iface.textContent = "";
+ return;
+ }
+ renderMiniApp(id, sel, iface);
+}
+
+// After a group thread's fold reveals or changes its added apps (open, live
+// event, reconnect catch-up), refresh the three places that reflect them: the
+// header widget bar, the split layout, and the Interface pane itself.
+function refreshGroupAppsUI(id) {
+ if (id !== activeId || threadApp[id] || !chatAppFor(id)) return;
+ if (!groupAppsFor(id).length) delete appThreadView[id]; // last app removed → back to plain chat
+ applyAppThreadView(id);
paintViewToggle(id);
+ renderGroupAppPane(id);
}
async function openThread(id) {
@@ -2939,6 +3026,12 @@ setDefaultBot(() => {
// as the "/" typeahead).
setThreadHasBot((id) => threadHasBot(id));
setThreadOwner((id) => threadApp[id] || null); // scopes local + default commands to an app's own thread
+// Which threads can host shared app instances — gates "@here" and the typeahead's
+// shared/personal hints to group chats.
+setThreadIsGroup((id) => {
+ const t = threads.find((x) => x.id === id);
+ return !!t && t.kind === "group";
+});
// Space info for Eleven's guided tour: the group name plus the optional
// administrator identity. The admin fields are member-gated, so read /api/group
// with our token; the tour degrades to just the name if it's unset or fails.
@@ -3481,6 +3574,15 @@ async function chatAppFoldMessage(threadId, m) {
const data = await decrypt(threadKeys[threadId], m.blob);
if (isAppType(data.type)) {
foldEvent(threadId, data, { id: m.id, ts: data.ts });
+ // The event also projects into the conversation — the same one-log-two-
+ // views idea as a companion app-thread: an app-thread's own event in the
+ // app's voice, a group/DM chat's in the group form (attributed action
+ // line / app bubble). Not gated on foldEvent's return: the event may
+ // already be inside the app's snapshot or a catch-up fold, but the chat
+ // app (snapshot: false) still needs the line, and its own per-message
+ // dedupe makes repeats no-ops.
+ if (threadApp[threadId]) foldAppEventBubble(threadId, threadApp[threadId], data, { id: m.id, ts: data.ts });
+ else foldGroupAppEventBubble(threadId, appForType(data.type), data, { id: m.id, ts: data.ts });
return;
}
if (data.type || (!data.text && !data.attachments)) return;
@@ -3549,6 +3651,35 @@ function foldAppEventBubble(threadId, ownerApp, event, meta) {
);
}
+// The group-chat sibling of foldAppEventBubble: in a chat with apps added, an
+// app's events project into the conversation too — but ATTRIBUTED. An action
+// projection leads with the actor ("Ciara added “buy milk”" — a group has many
+// hands, unlike a personal app-thread where every act is yours); a message
+// projection (a reminder firing) stays a bubble in the app's own voice. Apps
+// with no chatMessage stay silent in the feed, as everywhere.
+function foldGroupAppEventBubble(threadId, app, event, meta) {
+ if (!app || typeof app.chatMessage !== "function") return;
+ let line = null;
+ try {
+ // The { group: true } hint lets an app word a projection for many readers
+ // (Reminders' fire bubble drops "you asked me" in a group). Apps that
+ // don't care simply ignore the second argument.
+ line = app.chatMessage(event, { group: true });
+ } catch {}
+ const text = typeof line === "string" ? line : line && line.text;
+ if (!text) return;
+ const ts = event.ts || (meta && meta.ts) || Date.now();
+ const action = typeof line === "object" && !!line.action;
+ foldHostEvent(
+ threadId,
+ "chat",
+ action
+ ? { type: "chat.msg", name: event.name || "", text: `${event.name || "Someone"} ${text}`, uid: event.uid || "", ts, action: true }
+ : { type: "chat.msg", name: app.title || app.name, text, uid: "app:" + app.name, ts },
+ meta,
+ );
+}
+
// Fold one stored message of an app-thread for BOTH views at once: the owning
// app's state (App view) and a bot bubble (Chat view). A plain untyped note you
// typed in the chat view folds as an ordinary chat message.
@@ -3645,7 +3776,12 @@ async function openAppThreadView(id, gen, appName = threadApp[id], ownerApp = nu
const feed = pane.querySelector(".chatapp-feed");
if (feed && cached.scrollTop != null) feed.scrollTop = cached.scrollTop;
renderMiniApp(id, appName, pane);
- if (chatAppFor(id)) catchUpActiveMessages();
+ if (chatAppFor(id)) {
+ catchUpActiveMessages();
+ // Added-apps state may have changed while parked (live folds keep it
+ // current), and stashAppPane cleared the Interface pane — refresh both.
+ refreshGroupAppsUI(id);
+ }
return;
}
pane.textContent = "";
@@ -3672,6 +3808,9 @@ async function openAppThreadView(id, gen, appName = threadApp[id], ownerApp = nu
if (gen !== openSeq) return;
renderMiniApp(id, appName, pane);
appPaneCache[id] = { root: null, scrollTop: null, loaded: true };
+ // The fold may have revealed apps added to this group (snapshot + history)
+ // — the widget bar and Interface pane paint only now that state exists.
+ if (chatAppFor(id)) refreshGroupAppsUI(id);
} catch (e) {
console.error("open app-thread", e);
}
@@ -3684,10 +3823,13 @@ function applyAppThreadView(id) {
const tv = document.getElementById("thread-view");
if (!tv) return;
// A data-attribute drives the split CSS across the three views (chat | split |
- // interface). A COMPANION app-thread defaults to split; a normal chat OR a
- // takeover app-thread has NO view attribute (→ the single pane fills, the empty
- // interface pane hides), so neither can inherit a stale one.
+ // interface). A COMPANION app-thread defaults to split; a GROUP chat with
+ // apps added defaults to chat (a group is first a conversation — the widget
+ // bar is the way in); a normal chat OR a takeover app-thread has NO view
+ // attribute (→ the single pane fills, the empty interface pane hides), so
+ // neither can inherit a stale one.
if (threadApp[id] && appWantsChatView(threadApp[id]) && !appChatOnly(threadApp[id])) tv.dataset.appView = appThreadView[id] || "split";
+ else if (!threadApp[id] && chatAppFor(id) && groupAppsFor(id).length) tv.dataset.appView = appThreadView[id] || "chat";
else delete tv.dataset.appView;
}
@@ -3712,8 +3854,12 @@ function renderActiveSurfaces() {
return;
}
const appName = renderAppFor(activeId);
- if (appName) renderMiniApp(activeId, appName, document.getElementById("app-pane"));
- else renderSurfaces(activeId, document.getElementById("app-surface"));
+ if (appName) {
+ renderMiniApp(activeId, appName, document.getElementById("app-pane"));
+ // A group chat with an added app in view: its Interface pane folds off the
+ // same log, so redraw it alongside the conversation.
+ renderGroupAppPane(activeId);
+ } else renderSurfaces(activeId, document.getElementById("app-surface"));
}
// The id of your most recent message that's still editable (yours, has text, and
@@ -4465,6 +4611,7 @@ async function catchUpActiveMessages() {
if (id !== activeId) return;
for (const m of r.messages || []) await chatAppFoldMessage(id, m);
renderActiveSurfaces();
+ refreshGroupAppsUI(id); // an app add/remove may have landed while offline
} catch (e) {
console.error("catch up (lab)", e);
}
@@ -4734,7 +4881,15 @@ function connectWS() {
if (tid === activeId) renderActiveSurfaces();
} else {
foldEvent(tid, payload, { id: msg.id, ts: payload.ts });
- if (tid === activeId) renderActiveSurfaces();
+ // A group/DM chat: the event also projects into the conversation (an
+ // attributed action line / app bubble), like a companion thread's chat view.
+ if (chatAppFor(tid)) foldGroupAppEventBubble(tid, appForType(payload.type), payload, { id: msg.id, ts: payload.ts });
+ if (tid === activeId) {
+ renderActiveSurfaces();
+ // An app was added or removed: the header widget bar (and possibly
+ // the visible pane) changes for everyone, live.
+ if ((appForType(payload.type) || {}).name === "groupapps") refreshGroupAppsUI(tid);
+ }
}
notifyLive(tid, payload);
const pv = appPreview(payload);
@@ -5309,6 +5464,77 @@ function addPeople() {
card.appendChild(foot);
}
+// manageGroupApps adds or removes a trusted first-party app in the open group
+// (⋮ → Add apps): a sheet of the group-capable apps, each with an Add / Remove
+// action. The choice is an EVENT in the group's encrypted log (groupapps.add /
+// .remove) — every member folds it, so the header widget bar appears for the
+// whole group and the act shows as an action line in the conversation. The
+// server never learns which app was added, or that one was. Any member can add
+// or remove, like adding people.
+function manageGroupApps() {
+ const t = threads.find((x) => x.id === activeId);
+ if (!t || t.kind !== "group") return;
+ const tid = activeId;
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Apps";
+ card.appendChild(h);
+ const sub = document.createElement("p");
+ sub.className = "sub";
+ sub.textContent = "An app added here is shared — everyone in the group sees it beside the chat.";
+ card.appendChild(sub);
+ const candidates = groupCapableApps();
+ if (!candidates.length) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = "No apps can be added to groups yet.";
+ card.appendChild(p);
+ return;
+ }
+ const added = new Set(groupAppsFor(tid).map((a) => a.name));
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+ for (const app of candidates) {
+ const row = document.createElement("div");
+ row.className = "list-row static";
+ const av = document.createElement("span");
+ av.className = "avatar app-avatar";
+ av.textContent = app.glyph || "▦";
+ const body = document.createElement("span");
+ body.style.cssText = "flex:1;min-width:0;";
+ const nm = document.createElement("span");
+ nm.textContent = app.title || app.name;
+ body.appendChild(nm);
+ if (app.blurb) {
+ const bl = document.createElement("span");
+ bl.className = "helper-desc";
+ bl.textContent = app.blurb;
+ body.appendChild(bl);
+ }
+ const btn = document.createElement("button");
+ const isAdded = added.has(app.name);
+ btn.textContent = isAdded ? "Remove" : "Add";
+ if (isAdded) btn.className = "secondary";
+ btn.onclick = async () => {
+ btn.disabled = true;
+ const ok = await emitAppEvent(tid, { type: isAdded ? "groupapps.remove" : "groupapps.add", app: app.name });
+ if (!ok) {
+ // Socket down — nothing sent; let them retry rather than lie.
+ btn.disabled = false;
+ btn.textContent = "Couldn't send — try again";
+ return;
+ }
+ // The echo folds the change for everyone (us included) and refreshes the
+ // widget bar live — the sheet's job is done.
+ backdrop.remove();
+ };
+ row.append(av, body, btn);
+ list.appendChild(row);
+ }
+}
+
// Set a group photo: downscaled, encrypted with the THREAD key (so only members
// can see it), uploaded, and pointed at via the thread's avatar_blob.
async function leaveGroup() {
@@ -6433,10 +6659,17 @@ function typeaheadItems(tok) {
return emojiMatches(tok.query, 8).map((m) => ({ glyph: m.emoji, code: m.code, insert: m.emoji }));
}
const q = tok.query.toLowerCase();
- return roster
- .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q))
- .slice(0, 8)
- .map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " }));
+ // Apps added to THIS chat are mentionable too — "@To-do buy milk" runs the
+ // app's default command in place (see runAppMention). They rank after people.
+ const appMatches = (activeId && !threadApp[activeId] ? groupAppsFor(activeId) : [])
+ .filter((a) => a.defaultCommand && (a.title || a.name).toLowerCase().includes(q))
+ .map((a) => ({ name: a.title || a.name, glyph: a.glyph || "▦", insert: "@" + (a.title || a.name) + " " }));
+ return [
+ ...roster
+ .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q))
+ .map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " })),
+ ...appMatches,
+ ].slice(0, 8);
}
function renderTypeahead() {
diff --git a/web/static/style.css b/web/static/style.css
index d1625ae..f9f00d8 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1822,6 +1822,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.list-row:hover:not(.static) { background: var(--bubble-them); }
.list-row.static { cursor: default; }
.list-row input { width: auto; }
+/* The border is a BETWEEN-rows divider — the last row's would just float under
+ the list (same rule the thread list applies to its rows). */
+.pick-list .list-row:last-child { border-bottom: none; }
/* Helpers catalog: bots sit under their own picker heading, with a one-line blurb */
.pick-section {
padding: 16px 28px 6px; font-size: 12px; font-weight: 700; color: var(--muted);