vault: post a group action line when a secret is added or removed (#510)
* vault: post a group action line when a secret is added or removed
Vault stays a pure takeover on its personal thread (no Chat view ever
grows there), but when it's added to a group chat, an add/remove now
posts an unnamed action line ("Ciara added a secret to the Vault") so
members notice vault activity without opening it. A new groupChatOnly
flag pairs with chatMessage to split "projects into a group's chat
feed" from "wants its own Chat view" — appWantsChatView respects it,
foldGroupAppEventBubble doesn't need to know about it.
Co-Authored-By: Claude <noreply@anthropic.com>
* vault: say "You" for your own action, and fix the blank sidebar preview
Two follow-ups on the group action line:
- foldGroupAppEventBubble (shell.js) now substitutes "You" for the
acting user's own event, matching membershipLine's convention for
join/leave lines — applies to every app that projects into a
group's chat feed (vault, todo, remind, groupapps), not just vault.
- loadThreads() was computing the sidebar "last message" preview with
previewText() unconditionally, even for typed app events with no
.text — producing "Name: " with nothing after the colon. It now
routes app-typed payloads through the owning app's preview() hook,
same as the live-message path already did.
- vault.js gains a preview() hook (add/remove/change, "You"-aware via
a new currentUserId() export) so its own rows — personal or group —
show "🔒 You added a secret to the Vault" instead of falling back to
the (now-fixed) blank previewText output.
Co-Authored-By: Claude <noreply@anthropic.com>
* apps/README: document the "You" attribution split and the preview() gotcha
Two things this vault work surfaced that the next app author should
know up front, not rediscover:
- chatMessage action lines get "You" substitution for free from the
host (foldGroupAppEventBubble); preview() doesn't, and has to check
event.uid against the new currentUserId() export itself.
- Before this session, an app-typed event with no preview() handling
fell through to a raw "Name: " chat-message formatter (no .text ->
empty line after the colon) instead of staying silent. Now fixed at
the host level (both the live and cold-load paths), but it changes
the contract: every owned event type should either get a preview
line or be silenced on purpose, not left to a "reasonable" fallback
that no longer exists.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs/building-apps.md: document currentUserId() for a "You"-aware preview()
Mirrors the same addition to web/static/apps/README.md: preview()
gets no ctx, so an LLM building a custom app via /api/app-guide had
no documented way to say "You" for the reader's own event. The
export was already usable (check-app.mjs only restricts import
sources, not specific names from apps.js/format.js) — this just
surfaces it, with the allowed-imports table entry to match.
The other lesson from the same session (groupCapable/groupChatOnly's
host-level "You" attribution for group action lines) does NOT apply
here: this guide never teaches groupCapable/group-sharing at all
(scope is "personal"/"shared" only) — left for a separate call on
whether admin-installed custom apps should be taught that surface.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs/building-apps.md: teach groupCapable/groupChatOnly for custom apps
Extends the LLM-facing app-building guide (served at /api/app-guide)
with the group-sharing model previously only documented internally
(web/static/apps/README.md): groupCapable, the automatic actor/"You"
attribution on chatMessage action lines projected into a group,
chatMessage's { group: true } second argument, and groupChatOnly for
apps (like Vault) that want group activity noticed without their own
personal thread growing a Chat view.
Nothing here required a change to tools/check-app.mjs: it only
restricts import sources, not defineApp's field names, so groupCapable
apps were already installable via the self-serve custom-app path —
this guide just hadn't caught up to teach it.
Also clarifies the existing "personal or shared?" interview question,
which conflated scope: "shared" (Poll's always-on ambient panel) with
"a tool a group can share" (groupCapable) — the same "shared is not
shareable" trap called out in the internal README.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
7 files changed,
+245
−15
changelog/2026-07-09-vault-group-activity-line.md+4 −0docs/building-apps.md+83 −5test/apps.test.mjs+41 −0web/static/apps.js+13 −1web/static/apps/README.md+43 −1web/static/apps/vault.js+45 −5web/static/shell.js+16 −3
diff --git a/changelog/2026-07-09-vault-group-activity-line.md b/changelog/2026-07-09-vault-group-activity-line.md| new file mode 100644 |
| index 0000000..afab56e |
| --- /dev/null |
| +++ b/changelog/2026-07-09-vault-group-activity-line.md |
| @@ -0,0 +1,4 @@ |
| +🤖 In a group chat that has Vault added, adding or removing a secret now |
| +posts a quiet note in the conversation — "Ciara added a secret to the Vault" — |
| +so members notice vault activity without opening it. The secret's name and |
| +value are never shown; your own personal Vault is unaffected. |
diff --git a/docs/building-apps.md b/docs/building-apps.md| index b35cffd..d582fd8 100644 |
| --- a/docs/building-apps.md |
| +++ b/docs/building-apps.md |
| @@ -86,10 +86,19 @@ finished file against each rule: |
| Before writing any code, ask the person who wants the app: |
| -1. **Personal or shared?** Is this *their own private tool* (a to-do list, |
| - bookmarks — `scope: "personal"`, its own thread in the sidebar, full-pane) |
| - or a *group tool everyone in a chat uses together* (a poll, a scoreboard — |
| - `scope: "shared"`, a panel above the chat)? |
| +1. **Personal, ambient panel, or group-shareable?** Three different things, |
| + easy to conflate: |
| + - *Personal* (`scope: "personal"`) — their own private tool (a to-do list, |
| + bookmarks), its own thread in the sidebar, full-pane. |
| + - *Ambient panel* (`scope: "shared"`) — always present above every chat, no |
| + install (a poll, a scoreboard for that one conversation). |
| + - *Group-shareable* — a `scope: "personal"` app with `groupCapable: true` |
| + ALSO added to a specific group chat, where one shared instance runs for |
| + that group (see "Group-scoped apps" below). 🤖 **The trap**: someone |
| + asking for "a group tool everyone in the chat uses together" usually |
| + means THIS, not `scope: "shared"` — `"shared"` is the always-on panel |
| + model (Poll), not a "shareable" one. If the ask sounds like "a to-do list |
| + / tracker / list the group manages together," reach for `groupCapable`. |
| 2. **Takeover or companion?** For a personal app: should its surface *be* the |
| whole thread (takeover — the default; right for boards, games, dashboards), |
| or should the thread also offer a Chat view where the app's actions read as |
| @@ -126,11 +135,13 @@ export const app = defineApp({ /* fields below */ }); |
| | `title` | ✓ | Human name (sidebar, header). | |
| | `glyph` | ✓ | One emoji/character used as the app's avatar. | |
| | `scope` | ✓ | `"personal"` or `"shared"` — see the interview above. | |
| +| `groupCapable` | | `true` — this `"personal"` app can ALSO be added to a group chat, where one shared instance runs for everyone. See "Group-scoped apps" below. | |
| +| `groupChatOnly`| | `true` — pairs with `chatMessage`: the app still projects into a GROUP's chat feed when added there, but your OWN personal thread never grows a Chat view from it. See "Group-scoped apps" below. | |
| | `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. | |
| | `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. | |
| | `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }` — `desc`/`args` feed the `/` typeahead. | |
| | `render` | | `(state, el, ctx) => void`. Draws the surface. | |
| -| `chatMessage` | | `(event) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` → the app **takes over** the thread's full surface. | |
| +| `chatMessage` | | `(event, opts?) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` → the app **takes over** the thread's full surface. `opts.group` is `true` when called for a GROUP's own chat feed — see "Group-scoped apps" below. | |
| | `chatIntro` | | The empty-state intro line shown the first time the companion Chat view opens. | |
| | `messageAction`| | `(msg) => { label, glyph, run(ctx) } \| null`. Adds an item to the chat message menu. | |
| | `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. | |
| @@ -457,6 +468,11 @@ Both views fold from the same event log — one source of truth — so an action |
| in one shows in the other. No `chatMessage` → the app takes over the whole |
| thread surface (the default). |
| +🤖 If your app is `groupCapable` and gets added to a GROUP, `chatMessage` is |
| +also how its events show up in THAT group's own chat feed — see "Group-scoped |
| +apps" below for what changes (attribution, the `opts.group` argument, and |
| +`groupChatOnly`). |
| + |
| ### `preview(event)` / `unread(event)` |
| How your events appear in the chat list: `preview` returns the sidebar |
| @@ -464,6 +480,67 @@ preview line (or `null` to leave the existing preview), `unread` says whether |
| a live event lights the thread's unread dot. Both default to silent — opt in |
| only for events a user would want surfaced (a new poll: yes; every vote: no). |
| +🤖 `preview` gets no ctx — it's a pure `event => string`. If you want it to say |
| +“You” for the reader's own event (rather than always naming the actor), import |
| +`currentUserId` and compare it to `event.uid`: |
| + |
| +```js |
| +import { defineApp, currentUserId } from "../apps.js"; |
| +preview(e) { |
| + const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; |
| + return `${who} added “${e.text}”`; |
| +} |
| +``` |
| + |
| +## Group-scoped apps: personal AND shared with a group |
| + |
| +🤖 A `scope: "personal"` app with `groupCapable: true` can, IN ADDITION to |
| +everyone's own private instance, be **added to a specific group chat** |
| +(members do this from that chat's ⋮ menu → "Add apps" — no code of yours makes |
| +that happen, it's host UI). Once added, **one shared instance runs for that |
| +whole group**: every member's `ctx.emit` posts into the GROUP's own encrypted |
| +event log instead of their personal thread, and every member folds the same |
| +state. Nothing about your `render`, `events`, or `commands` changes — `ctx` is |
| +just bound to whichever thread it's acting in, so the exact same code renders |
| +a to-do list whether it's yours alone or the group's shared one. |
| + |
| +This is a different mechanism from `scope: "shared"` (the ambient panel, e.g. |
| +Poll — always present, no install). Don't reach for `groupCapable` AND |
| +`scope: "shared"` together; pick one shape (see the interview above). |
| + |
| +What changes once your app can be group-instanced: |
| + |
| +- **Any group member's event is now a first-class input**, not just yours. |
| + Hard constraint #11 ("validate in the reducer, never trust the emitter") is |
| + no longer a defensive nicety — a malformed or adversarial event from ANY |
| + member of the group must fold to a no-op, because it can now come from |
| + someone other than you. |
| +- **`chatMessage` action lines get attributed automatically.** When a group |
| + has your app added, an `{ text, action: true }` projection is shown prefixed |
| + with the actor's name, or **“You”** when the reader is the one who acted — |
| + both entirely automatic. Write `text` as if it always follows a name |
| + (`added “buy milk”`, never `Ciara added “buy milk”`) — the host supplies |
| + the name/“You”, so baking one in yourself produces a doubled-up line. |
| +- **`chatMessage` receives a second argument in a group: `chatMessage(event, |
| + { group: true })`.** Use it only if wording needs to change for many |
| + simultaneous readers (e.g. a reminder's `"you asked me"` phrasing only makes |
| + sense one-to-one, so a group projection drops it). Ignore the second |
| + argument if your wording doesn't depend on it. |
| +- **`groupChatOnly: true` splits "projects into a group's feed" from "wants |
| + its own Chat view."** By default, implementing `chatMessage` makes ANY |
| + instance of your app — personal or group — a companion (Chat view + toggle). |
| + Set `groupChatOnly: true` alongside `chatMessage` to keep your app's own |
| + personal thread a pure takeover (no Chat view, ever), while STILL projecting |
| + an attributed line into a group's feed when the app is added there. Reach |
| + for this when your personal surface shouldn't grow a second (redundant, or |
| + privacy-sensitive) view of its own events, but a group that shares an |
| + instance should still notice activity without opening the app — e.g. a |
| + shared secrets vault that announces "Ciara added a secret" to the group |
| + without ever listing secret names in anyone's Chat view. |
| +- **`preview`/`unread` work exactly the same** for a group instance as a |
| + personal one — no group-specific argument, just whatever `event.name` / |
| + `event.uid` the event already carries. |
| + |
| ## Allowed imports |
| Import **only** from these two modules, and only these exports. |
| @@ -479,6 +556,7 @@ From `"../apps.js"`: |
| | `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp — for "linger then tuck away" UI | |
| | `DONE_GRACE_MS` | the standard grace-period length (ms) | |
| | `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces | |
| +| `currentUserId()` | the reader's own user id — for a "You" check in `preview` (see above) or `render` | |
| (`apps.js` exports other functions — `setEmitTransport`, `foldEvent`, |
| `runCommand`, … — those are host/framework wiring. An app must not call them.) |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index edee8a7..a08a1a9 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -62,6 +62,7 @@ import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run |
| import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app |
| import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| // Each app's fold is synthesised from its `events` map by defineApp — the same |
| // (state, event) → state reducer the framework folds with. Test it directly. |
| @@ -367,6 +368,46 @@ await test("appWantsChatView: chatMessage opts into companion; its absence is ta |
| assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion |
| }); |
| +await test("appWantsChatView: groupChatOnly keeps a chatMessage app a personal takeover", () => { |
| + // Vault implements chatMessage (to project add/remove into a GROUP's chat |
| + // feed — see below) but is groupChatOnly, so its OWN thread must stay a pure |
| + // takeover: no Chat view, no header toggle, ever. |
| + assert.equal(typeof vaultApp.chatMessage, "function"); |
| + assert.equal(vaultApp.groupChatOnly, true); |
| + assert.equal(appWantsChatView("vault"), false); |
| +}); |
| + |
| +await test("vault: chatMessage projects add/remove only in a GROUP, never names the secret", () => { |
| + const add = { type: "vault.add", id: "s1", aid: "a1", label: "GITHUB_TOKEN", value: "hunter2" }; |
| + const remove = { type: "vault.remove", id: "s1", aid: "a2" }; |
| + const change = { type: "vault.change", id: "s1", aid: "a3", value: "newvalue" }; |
| + // No { group: true } — a personal-thread event (or a call with no opts at |
| + // all) → silent, exactly like an app with no chatMessage. |
| + assert.equal(vaultApp.chatMessage(add), null); |
| + assert.equal(vaultApp.chatMessage(add, {}), null); |
| + assert.equal(vaultApp.chatMessage(remove), null); |
| + // In a group: add/remove get an unnamed action line… |
| + const groupAdd = vaultApp.chatMessage(add, { group: true }); |
| + assert.equal(groupAdd.action, true); |
| + assert.equal(groupAdd.text, "added a secret to the Vault"); |
| + assert.doesNotMatch(groupAdd.text, /GITHUB_TOKEN|hunter2/); |
| + const groupRemove = vaultApp.chatMessage(remove, { group: true }); |
| + assert.equal(groupRemove.action, true); |
| + assert.equal(groupRemove.text, "removed a secret from the Vault"); |
| + // …but a value CHANGE (nothing new to notice, same name) stays silent. |
| + assert.equal(vaultApp.chatMessage(change, { group: true }), null); |
| +}); |
| + |
| +await test("vault: preview names the actor ('You' for the reader's own event), never the secret", () => { |
| + // setCurrentUser("u1") above makes u1 "the reader" for the rest of this file. |
| + assert.equal(vaultApp.preview({ type: "vault.add", uid: "u1", name: "James" }), "🔒 You added a secret to the Vault"); |
| + assert.equal(vaultApp.preview({ type: "vault.remove", uid: "u2", name: "Ciara" }), "🔒 Ciara removed a secret from the Vault"); |
| + assert.equal(vaultApp.preview({ type: "vault.change", uid: "u1", name: "James" }), "🔒 You updated a secret in the Vault"); |
| + // No uid (a legacy/malformed event) falls back to the sender's name, not "You". |
| + assert.equal(vaultApp.preview({ type: "vault.add", name: "James" }), "🔒 James added a secret to the Vault"); |
| + assert.equal(vaultApp.preview({ type: "vault.other" }), null); |
| +}); |
| + |
| await test("defineApp reconciles the legacy scope enum with the personal/listed flags (#405)", () => { |
| const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec }); |
| // Legacy scope declarations derive concrete flags… |
diff --git a/web/static/apps.js b/web/static/apps.js| index 3b27653..d2074ea 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -325,9 +325,15 @@ export function personalApps() { |
| // its render() is the whole thread, no chat view and no toggle (the original |
| // full-pane model). "Wants a chat view" and "knows how to project into chat" are |
| // the same fact, so companion stays opt-in and never mandatory — see CLAUDE.md. |
| +// |
| +// 🤖 `groupChatOnly: true` splits that fact in two: the app still implements |
| +// chatMessage (so foldGroupAppEventBubble can project its events into a GROUP |
| +// chat that has it added — see shell.js), but its personal thread stays a pure |
| +// takeover. For a personal-instance secrets/notes tool that wants group activity |
| +// visible without ever growing a Chat view of its own private thread (the Vault). |
| export function appWantsChatView(name) { |
| const app = appByName(name); |
| - return !!(app && typeof app.chatMessage === "function"); |
| + return !!(app && typeof app.chatMessage === "function" && !app.groupChatOnly); |
| } |
| // appDefaultView is the view a companion app-thread opens in when the user |
| @@ -729,6 +735,12 @@ export function setCurrentUser(uid, name) { |
| currentUser = uid; |
| currentUserName = name || null; |
| } |
| +// 🤖 currentUserId lets a `preview(event)` hook say "You" for the reader's own |
| +// event — preview hooks take no ctx (they're pure `event => string`), so this |
| +// is the one narrow door to "who's reading" outside a full ctx (see vault.js). |
| +export function currentUserId() { |
| + return currentUser; |
| +} |
| let resolveAppThread = null; // (appName) → Promise<threadId> |
| export function setAppThreadResolver(fn) { |
| resolveAppThread = fn; |
diff --git a/web/static/apps/README.md b/web/static/apps/README.md| index 0caacc1..f22b065 100644 |
| --- a/web/static/apps/README.md |
| +++ b/web/static/apps/README.md |
| @@ -176,7 +176,13 @@ Once you've picked a shape, copy the closest working app rather than starting co |
| A shape decision worth making deliberately: **a takeover secrets/notes tool should |
| usually NOT implement `chatMessage`**, so its events don't spill names into the |
| -chat feed, previews, and push notifications (a secrets vault is best built this way). |
| +chat feed, previews, and push notifications. 🤖 When a group still needs to *see |
| +that something happened* (without becoming a companion itself), pair |
| +`chatMessage` with **`groupChatOnly: true`**: the personal thread stays a pure |
| +takeover (no Chat view ever grows on it — `appWantsChatView` ignores it), while |
| +a group that's added the app still gets an attributed action line via |
| +`foldGroupAppEventBubble`. `vault.js` uses this for add/remove, deliberately |
| +without naming the secret in the line. |
| ## `defineApp(spec)` — the fields |
| @@ -187,6 +193,7 @@ chat feed, previews, and push notifications (a secrets vault is best built this |
| | `glyph` | ✓ | One emoji/character used as the app's avatar. | |
| | `personal` | | `true` if the app may have a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`). Defaults to `true`. | |
| | `groupCapable` | | `true` if the app can ALSO be added to a group chat (⋮ → Add apps), where one shared instance runs for everyone. Orthogonal to `personal`. See "Group-scoped apps". | |
| +| `groupChatOnly`| | 🤖 Pairs with a `chatMessage` hook: the app still projects into a GROUP chat's feed, but its own personal thread stays a takeover (never grows a Chat view). See "Companion projection" above; `vault.js` is the example. | |
| | `listed` | | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `true`. | |
| | `scope` | | **Deprecated** — being dissolved into the flags above (#405). Still accepted: `"personal"`/`"shared"`/`"system"` derive the flags, and vice-versa. See "Instance & discovery flags". | |
| | `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked — how a service "installs" its companion app. See `docs/connected-services.md`. | |
| @@ -274,6 +281,13 @@ model; it's the existing one pointed at a different thread: |
| 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. |
| + 🤖 The host also substitutes **“You”** for the reader's own action |
| + (`foldGroupAppEventBubble` in `shell.js`, matching `membershipLine`'s |
| + join/leave convention) — this is automatic and free; write your `text` as if |
| + it always follows a name (`added “buy milk”`), never bake in “You” yourself, |
| + or a bystander reading someone else's event will see “You added “buy milk””. |
| + This attribution is unique to `chatMessage` — a `preview` hook (below) gets |
| + no such help and must do its own “You” check. |
| - **@-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 `@` |
| @@ -701,6 +715,34 @@ lights the thread's unread dot. Both default to silent (the old behaviour); |
| poll opts in — a new poll previews as `📊 <question>` and marks unread, while |
| votes stay quiet. Push wording for app events is the `notice` field (Voice). |
| +🤖 `preview` gets no `{ group }`-style hint and no ctx — it's a pure |
| +`event => string`, called for BOTH your personal thread's row and a group's. |
| +Two things worth knowing before you write one (learned adding vault.js's): |
| + |
| +- **It runs your event through, not a generic formatter.** Before `preview` |
| + existed for a given type, the host fell back to treating the raw typed event |
| + as if it were a plain chat message (reading `.text`) — which a typed event |
| + doesn't have, so a group row read as the sender's name, a colon, and nothing |
| + (`"Ciara: "`). This is fixed at the host level (both the live path and the |
| + cold-load path in `shell.js`'s `loadThreads` now call `preview()` for any |
| + typed event, updating the row only when it returns non-null) — but it means |
| + **any event type your reducer owns should either have a `preview` line or |
| + stay silent on purpose**; there's no longer a "raw" fallback to fall into by |
| + accident, so don't leave a type unhandled by assuming something reasonable |
| + will show. |
| +- **"You" is on you.** Unlike `chatMessage` (attributed by the host, see |
| + above), `preview` has to do its own "your own event" check — import |
| + `currentUserId` from `../apps.js` and compare it to `event.uid`: |
| + ```js |
| + import { defineApp, currentUserId } from "../apps.js"; |
| + preview(e) { |
| + const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; |
| + return `${who} did the thing`; |
| + } |
| + ``` |
| + `currentUserId()` exists for exactly this — a narrow door to "who's reading" |
| + for the pure hooks (`preview`, and any future one) that don't get a `ctx`. |
| + |
| ### Embedded (native shell) mode |
| The iOS/macOS shell renders one thread per webview and draws its own composer, |
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js| index d7f9cc9..30ba916 100644 |
| --- a/web/static/apps/vault.js |
| +++ b/web/static/apps/vault.js |
| @@ -12,10 +12,20 @@ |
| // member folds the same vault. Nothing about render() changes — the same |
| // surface, ctx bound to whichever thread it's acting in. |
| // |
| -// It is a TAKEOVER app (no `chatMessage`): the vault IS the thread's surface, and |
| -// its events deliberately DON'T project into the chat feed — a secrets tool must |
| -// not spill "Ana added GITHUB_TOKEN" into the conversation, its previews, or push |
| -// notifications. The audit trail lives inside the vault surface instead. |
| +// It is a TAKEOVER app (`groupChatOnly: true`, not a bare `chatMessage`): the |
| +// vault IS the thread's surface — your personal Vault never grows a Chat view, |
| +// and no secret's NAME ever spills into the conversation, a sidebar preview, or |
| +// push. 🤖 Two places still surface UNNAMED activity, "you"-aware (same as a |
| +// membership line — see `format.js`'s `membershipLine`): |
| +// - The chat list's "last message" preview (`preview` below) — "You added a |
| +// secret to the Vault" / "Ciara updated a secret" — for the vault's own |
| +// thread (personal or group), so the row doesn't sit blank. |
| +// - When the vault is added to a GROUP, an add/remove also posts the same |
| +// unnamed line into that group's chat feed (`chatMessage`'s `{ group }` |
| +// form), so members notice without opening it. |
| +// The name (which — unlike the value — every member can already see inside the |
| +// vault) never lands in either place. The full audit trail, WITH names, still |
| +// lives only inside the vault surface. |
| // |
| // Two ideas beyond a plain list: |
| // - Locked by default. A value renders masked (••••••) and only reveals when you |
| @@ -30,7 +40,7 @@ |
| // |
| // Relative import (not "/static/apps.js") so the reducer is exercisable in Node |
| // tests while still resolving to /static/apps.js in the browser. |
| -import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; |
| +import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js"; |
| // How long a revealed value stays visible before it re-locks itself — short, so a |
| // vault left open on screen doesn't sit unmasked. Clicking it again re-arms it. |
| @@ -81,6 +91,7 @@ export const app = defineApp({ |
| glyph: "🔒", |
| scope: "personal", |
| groupCapable: true, // can also be added to a group chat, one shared instance for all |
| + groupChatOnly: true, // chatMessage below projects into a GROUP's chat feed only — never a Chat view of the personal thread (see apps/README.md "Companion projection") |
| initialState: () => ({ items: [], log: [] }), |
| events: { |
| "vault.add": (s, e) => { |
| @@ -138,6 +149,35 @@ export const app = defineApp({ |
| }, |
| }, |
| + // 🤖 chatMessage + `groupChatOnly: true` (above) is a GROUP-ONLY projection: |
| + // `opts.group` is only set when the host folds a GROUP/DM chat's own feed |
| + // (foldGroupAppEventBubble in shell.js), never for the personal thread's |
| + // events — so this returns null there, same as having no chatMessage at all |
| + // (see apps/README.md "Companion projection"). Deliberately unnamed and |
| + // deliberately narrow: an ADD or REMOVE is worth a group noticing ("someone |
| + // touched the vault"), but the secret's name stays out of the conversation, |
| + // its previews, and push — and a value CHANGE (no new/gone item, same name) |
| + // stays silent, since there's nothing new for a passerby to learn from it. |
| + chatMessage(e, opts) { |
| + if (!opts || !opts.group) return null; |
| + if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true }; |
| + if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true }; |
| + return null; |
| + }, |
| + |
| + // 🤖 The sidebar's "last message" line, for both a personal Vault thread and |
| + // a group's — same unnamed shape as chatMessage above (never the secret's |
| + // name), but always on (not `groupChatOnly`-gated: a preview line doesn't |
| + // grow a Chat view, it's just text on a row you already see). "You" for your |
| + // own action, same idea as the group action line's actor substitution. |
| + preview(e) { |
| + const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; |
| + if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`; |
| + if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`; |
| + if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`; |
| + return null; |
| + }, |
| + |
| // This app's CSS travels with it — the framework injects it once. It reads the |
| // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme. |
| styles: ` |
diff --git a/web/static/shell.js b/web/static/shell.js| index b8086b7..da80462 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1248,7 +1248,17 @@ async function loadThreads() { |
| if (t.last_blob && threadKeys[t.id]) { |
| try { |
| const payload = await decrypt(threadKeys[t.id], t.last_blob); |
| - threadPreview[t.id] = previewText(t, payload); |
| + // A typed app event (e.g. vault.add) has no .text — previewText would |
| + // read it as an empty chat message and render "Name: " with nothing |
| + // after the colon. Route it through the OWNING APP's preview hook |
| + // instead (same as the live path below), leaving the row's stale |
| + // preview in place when the app declares none (silent, not blank). |
| + if (isAppType(payload.type)) { |
| + const pv = appPreview(payload); |
| + if (pv) threadPreview[t.id] = pv; |
| + } else { |
| + threadPreview[t.id] = previewText(t, payload); |
| + } |
| // Seed the unread dot from the synced watermark, applying the same |
| // gates as a live frame: your own last message never lights it (you |
| // sent it, or replying elsewhere means you read it), and app events |
| @@ -3978,7 +3988,9 @@ function foldAppEventBubble(threadId, ownerApp, event, meta) { |
| // 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. |
| +// with no chatMessage stay silent in the feed, as everywhere. The actor reads |
| +// "You" for the reader's own action, same as a membership line (membershipLine |
| +// in format.js) — an app never needs to know who's reading to get this right. |
| function foldGroupAppEventBubble(threadId, app, event, meta) { |
| if (!app || typeof app.chatMessage !== "function") return; |
| let line = null; |
| @@ -3992,11 +4004,12 @@ function foldGroupAppEventBubble(threadId, app, event, meta) { |
| if (!text) return; |
| const ts = event.ts || (meta && meta.ts) || Date.now(); |
| const action = typeof line === "object" && !!line.action; |
| + const actorName = event.uid && event.uid === myUserId ? "You" : event.name || "Someone"; |
| 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: event.name || "", text: `${actorName} ${text}`, uid: event.uid || "", ts, action: true } |
| : { type: "chat.msg", name: app.title || app.name, text, uid: "app:" + app.name, ts }, |
| meta, |
| ); |