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>
15 files changed,
+974
−45
.claude/memory/event-contract.md+1 −0changelog/2026-07-08-group-apps.md+15 −0test/apps.test.mjs+222 −3test/event-vectors.test.mjs+19 −0test/event-vectors/groupapps.json+123 −0test/event-vectors/index.js+2 −0web/pages/index.html+1 −0web/static/apps.js+206 −7web/static/apps/README.md+69 −0web/static/apps/bookmarks.js+5 −0web/static/apps/chat.js+17 −2web/static/apps/remind.js+13 −3web/static/apps/todo.js+16 −1web/static/shell.js+262 −29web/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); |