eleven / ocho Public

web: Admin app gains a read-only update stream (Announcer retirement, part 1) (#460)

* web: Admin app — Settings | What's New view toggle + read-only release-note feed

The Admin app gains an internal view switcher: Settings (default, full-screen)
and What's New — a read-only feed of the space's release notes, the admin-only
home for "🚀 Just shipped" in place of the retired Announcer. One thread, two
views: the App and its "chat" are the same surface (no composer, no second
sidebar entry).

- admin.js: tab bar + panes; What's New renders /api/changelog as dated,
  chat-style bubbles with a "new since last looked" divider + a per-origin
  seen-watermark (a dot on the tab). First-ever open baselines silently (no
  backlog blast), mirroring the planned server-side announce.
- shell.js: host bridge gains changelog() — the app holds no token of its own.
- format.js: changelogParagraphs (drop the authorship marker, collapse hard
  wraps into paragraphs) + changelogDateParts — pure, unit-tested.

Part 1 of retiring the Announcer: announcements become app-rendered from the
embedded changelog (plaintext, non-user-content), so this path needs no
sidecar. Server push to admins + native land in follow-up PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: Admin becomes an interface-first companion — release notes post into a read-only chat stream

Rework of the previous commit's bespoke tab UI, using the platform's companion
machinery instead (per review):

- admin.js: opts into the companion view via chatMessage; each new release
  note is POSTED into the thread as an admin.note event and projects as a
  bubble from "Admin" — a real chat stream, one log, two views. First-ever
  run baselines silently (admin.baseline — no backlog blast). Emits retry
  briefly: the thread often renders before the socket is up after a reload,
  and an idle thread never re-renders.
- apps.js: two small primitives — appDefaultView (an app may declare
  defaultView: "interface" so it opens as the app, not the split) and
  appChatReadOnly (the app posts into its chat; the member doesn't).
- chat.js: hides the composer for read-only app chats (same pattern as the
  native embed).
- shell.js: companion view defaults honour appDefaultView.
- vectors: test/event-vectors/admin.json pins the fold + projection contract
  (webview renders this thread on native, so there's no native fold to sync).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: admin — guard getState() on a virgin thread (the announce never ran)

appStateFor() is a bare Map.get: undefined until a thread's FIRST event folds
— and on a brand-new Admin thread the baseline announceNew is about to post IS
that first event. Reading .seen off undefined threw, the catch swallowed it,
and the retry re-threw forever: no baseline, no notes, silently. Found by the
headless verify harness (14 assertions now pass end-to-end: interface-default,
companion toggle, read-only chat, silent baseline, note → Admin bubble).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* changelog: the Admin update stream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell b418ba8c571a453e12562db5b7895b6b77a4f6cf parent 95591b4
11 files changed, +322 −11
  • changelog/2026-07-09-admin-update-stream.md +4 −0
  • test/apps.test.mjs +28 −2
  • test/event-vectors.test.mjs +42 −0
  • test/event-vectors/admin.json +62 −0
  • test/event-vectors/index.js +2 −0
  • test/format.test.mjs +25 −1
  • web/static/apps.js +21 −0
  • web/static/apps/admin.js +92 −3
  • web/static/apps/chat.js +9 −1
  • web/static/format.js +16 −0
  • web/static/shell.js +21 −4
diff --git a/changelog/2026-07-09-admin-update-stream.md b/changelog/2026-07-09-admin-update-stream.md
new file mode 100644
index 0000000..919f5c5
--- /dev/null
+++ b/changelog/2026-07-09-admin-update-stream.md
@@ -0,0 +1,4 @@
+🤖 The Admin app now keeps a running stream of what's new: release notes
+like this one are posted into its chat view by Admin, so admins have one
+tidy place to see what changed. Open the Admin app and tap the 💬 toggle
+in the header to read it.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 1af172b..edee8a7 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -36,6 +36,7 @@ import {
keyedList,
appWantsChatView,
appChatOnly,
+ appDefaultView,
noticeParts,
noticeIsCompact,
noticeCalmsDown,
@@ -1353,13 +1354,38 @@ await test("setup: chat-only projection — completed steps become action lines"
assert.equal(setupApp.scope, "system", "never offered in the Apps menu");
});
-await test("admin: system-scope takeover, badged as an app-persona's opposite", () => {
+await test("admin: interface-first companion with a read-only update stream", () => {
assert.equal(adminApp.scope, "system", "never offered in the Apps menu");
- assert.equal(appWantsChatView("admin"), false, "no chatMessage → takeover (the panel IS the thread)");
+ // The panel leads: a companion (chatMessage → Chat view exists) that opens in
+ // its Interface, with the update stream one toggle away — and read-only there.
+ assert.equal(appWantsChatView("admin"), true, "chatMessage → companion (the update stream is the Chat view)");
+ assert.equal(appChatOnly("admin"), false, "the Interface (settings panel) is a real view");
+ assert.equal(appDefaultView("admin"), "interface", "opens as the app, not the split");
+ assert.equal(appDefaultView("todo"), "split", "companions without a preference keep the split default");
+ assert.equal(adminApp.chatReadOnly, true, "the app posts into the stream; the admin doesn't");
assert.equal(adminApp.senderKind, undefined, "Admin reads as an APP; only setup overrides to BOT");
assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP");
});
+await test("admin: release notes fold as a seen-watermark and project as Admin bubbles", () => {
+ const r = adminApp.reduce;
+ // A posted note records its id; re-folding the same id is a no-op (two devices
+ // may race the same entry).
+ let s = r(undefined, { type: "admin.note", id: "2026-07-08-read-sync", text: "Unread dots now sync." });
+ assert.deepEqual(s.seen, ["2026-07-08-read-sync"]);
+ assert.equal(r(s, { type: "admin.note", id: "2026-07-08-read-sync", text: "dup" }), s, "duplicate id → unchanged");
+ assert.equal(r(s, { type: "admin.note", text: "no id" }), s, "id-less note → unchanged");
+ // First-run baseline: everything current marked seen, and merges are a union.
+ s = r(undefined, { type: "admin.baseline", ids: ["a", "b"] });
+ assert.deepEqual(s.seen, ["a", "b"]);
+ assert.deepEqual(r(s, { type: "admin.baseline", ids: ["b", "c"] }).seen, ["a", "b", "c"], "second baseline merges");
+ // Projections: a note is a bubble from Admin (a string, not an action line);
+ // the baseline is silent housekeeping.
+ assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x", text: "Shipped a thing." }), "Shipped a thing.");
+ assert.equal(adminApp.chatMessage({ type: "admin.note", id: "x" }), null, "text-less note → silent");
+ assert.equal(adminApp.chatMessage({ type: "admin.baseline", ids: ["a"] }), null);
+});
+
await test("welcome: the joiner conversation mirrors setup's shape", () => {
const r = welcomeApp.reduce;
const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" });
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index 83c5944..98433f9 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -395,4 +395,46 @@ allVectors.groupapps.forEach((vector) => {
});
});
+// Admin app fold handlers (web/static/apps/admin.js) — the update stream.
+// `seen` is the posted-entry watermark; note folds are idempotent, baselines merge.
+function testAdminNoteFold(state, event) {
+ if (typeof event.id !== "string" || !event.id || (state.seen || []).includes(event.id)) return state;
+ return { seen: [...(state.seen || []), event.id] };
+}
+
+function testAdminBaselineFold(state, event) {
+ if (!Array.isArray(event.ids)) return state;
+ return { seen: [...new Set([...(state.seen || []), ...event.ids.filter((x) => typeof x === "string" && x)])] };
+}
+
+// The chat projection: a note is a bubble from Admin (a plain string — never an
+// action line); a baseline is silent (null).
+function testAdminChatProjection(event) {
+ if (event.type === "admin.note" && event.text) return event.text;
+ return null;
+}
+
+// Test admin vectors (the update stream in place of the retired Announcer)
+allVectors.admin.forEach((vector) => {
+ test(`admin: ${vector.name} — folds to the expected state`, () => {
+ const state = { seen: (vector.initial_state && vector.initial_state.seen) || [] };
+ const result = vector.event.type === "admin.baseline" ? testAdminBaselineFold(state, vector.event) : testAdminNoteFold(state, vector.event);
+ assert.deepStrictEqual(result.seen, vector.expected_state_update.seen, "seen watermark should match the vector exactly");
+ });
+
+ if ("expected_chat_projection" in vector) {
+ test(`admin: ${vector.name} — chat projection matches`, () => {
+ const line = testAdminChatProjection(vector.event);
+ const expected = vector.expected_chat_projection;
+ if (expected === null) {
+ assert.strictEqual(line, null, "projection should be silent");
+ } else {
+ assert.strictEqual(typeof line, "string", "a note projects as a message bubble (a string), not an action line");
+ assert.strictEqual(line, expected.text, "bubble text should match");
+ assert.strictEqual(expected.action, false, "a note is never an action line");
+ }
+ });
+ }
+});
+
if (fails) process.exit(1);
diff --git a/test/event-vectors/admin.json b/test/event-vectors/admin.json
new file mode 100644
index 0000000..c461b5a
--- /dev/null
+++ b/test/event-vectors/admin.json
@@ -0,0 +1,62 @@
+{
+ "description": "Admin app event vectors: the update stream (release notes posted into the Admin thread in place of the retired Announcer bot). Ensures the fold + chat projection stay consistent. Source of truth: web/static/apps/admin.js",
+ "app": "admin",
+ "source": "web/static/apps/admin.js",
+ "state_shape": { "seen": ["string (changelog entry id)"] },
+ "notes": [
+ "admin.note posts one release note as a bubble from Admin; `seen` (posted ids) is the cross-device watermark.",
+ "admin.baseline marks every current entry seen WITHOUT posting — the first-run 'no backlog blast' rule.",
+ "The Chat view is read-only (chatReadOnly): the app posts, members never do. Native renders this thread via the webview, so there is no native fold to keep in sync — these vectors pin the web contract."
+ ],
+ "vectors": [
+ {
+ "name": "admin_note",
+ "description": "A release note is posted: its id joins `seen`, and it projects as a message bubble from Admin (not an action line).",
+ "event": {
+ "type": "admin.note",
+ "id": "2026-07-08-read-sync",
+ "text": "Unread dots now sync across your devices.",
+ "name": "Paul",
+ "ts": 1700000000000
+ },
+ "initial_state": { "seen": [] },
+ "expected_state_update": { "seen": ["2026-07-08-read-sync"] },
+ "expected_chat_projection": {
+ "text": "Unread dots now sync across your devices.",
+ "action": false
+ }
+ },
+ {
+ "name": "admin_note_duplicate",
+ "description": "Re-folding a note whose id is already seen leaves state unchanged (idempotent — two devices may race the same entry).",
+ "event": {
+ "type": "admin.note",
+ "id": "2026-07-08-read-sync",
+ "text": "Unread dots now sync across your devices."
+ },
+ "initial_state": { "seen": ["2026-07-08-read-sync"] },
+ "expected_state_update": { "seen": ["2026-07-08-read-sync"] }
+ },
+ {
+ "name": "admin_baseline",
+ "description": "First-run baseline: every current entry id is recorded as seen, nothing is posted, and the projection is silent (null).",
+ "event": {
+ "type": "admin.baseline",
+ "ids": ["2026-07-07-member-apps", "2026-07-08-read-sync"]
+ },
+ "initial_state": { "seen": [] },
+ "expected_state_update": { "seen": ["2026-07-07-member-apps", "2026-07-08-read-sync"] },
+ "expected_chat_projection": null
+ },
+ {
+ "name": "admin_baseline_merge",
+ "description": "A second baseline merges (set union, order-preserving) rather than duplicating ids.",
+ "event": {
+ "type": "admin.baseline",
+ "ids": ["2026-07-08-read-sync", "2026-07-08-text-size"]
+ },
+ "initial_state": { "seen": ["2026-07-08-read-sync"] },
+ "expected_state_update": { "seen": ["2026-07-08-read-sync", "2026-07-08-text-size"] }
+ }
+ ]
+}
diff --git a/test/event-vectors/index.js b/test/event-vectors/index.js
index eaf3f4d..b952bc7 100644
--- a/test/event-vectors/index.js
+++ b/test/event-vectors/index.js
@@ -8,6 +8,7 @@ export const chatVectors = JSON.parse(readFileSync(join(__dirname, "chat.json"),
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"));
+export const adminVectors = JSON.parse(readFileSync(join(__dirname, "admin.json"), "utf8"));
// Flatten all vectors for easy iteration in tests
export const allVectors = {
@@ -15,4 +16,5 @@ export const allVectors = {
todo: todoVectors.vectors || [],
remind: remindVectors.vectors || [],
groupapps: groupAppsVectors.vectors || [],
+ admin: adminVectors.vectors || [],
};
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 9e050bc..a9aa3c1 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId } from "../web/static/format.js";
+import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs } from "../web/static/format.js";
import { emojify, emojiMatches } from "../web/static/emoji.js";
let failures = 0;
@@ -400,5 +400,29 @@ await test("youtubeId rejects non-video and lookalike URLs", () => {
assert.equal(youtubeId("ftp://youtu.be/dQw4w9WgXcQ"), null);
});
+// ── changelog cleaning (the Admin app's update stream) ──
+
+await test("changelogParagraphs drops the 🤖 authorship marker", () => {
+ assert.deepEqual(changelogParagraphs("🤖 Unread dots now sync across your devices."), [
+ "Unread dots now sync across your devices.",
+ ]);
+ // A person marker is stripped the same way.
+ assert.deepEqual(changelogParagraphs("👨 Hand-written note."), ["Hand-written note."]);
+});
+
+await test("changelogParagraphs collapses hard wraps but keeps paragraph breaks", () => {
+ const note = "🤖 Your space can now keep itself up\nto date. Admins get a new\nUpdates section.\n\nUpdates take seconds.";
+ assert.deepEqual(changelogParagraphs(note), [
+ "Your space can now keep itself up to date. Admins get a new Updates section.",
+ "Updates take seconds.",
+ ]);
+});
+
+await test("changelogParagraphs handles no marker, blank, and non-string", () => {
+ assert.deepEqual(changelogParagraphs("Plain note, no marker."), ["Plain note, no marker."]);
+ assert.deepEqual(changelogParagraphs(" "), []);
+ assert.deepEqual(changelogParagraphs(undefined), []);
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps.js b/web/static/apps.js
index 8218f51..ca3b965 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -330,6 +330,27 @@ export function appWantsChatView(name) {
return !!(app && typeof app.chatMessage === "function");
}
+// appDefaultView is the view a companion app-thread opens in when the user
+// hasn't picked one this session: "split" unless the app says otherwise. An
+// interface-first companion (the Admin app — a settings panel whose chat is a
+// read-only notice stream) declares `defaultView: "interface"` so it opens as
+// the app, with the conversation one toggle away.
+export function appDefaultView(name) {
+ const app = appByName(name);
+ const v = app && app.defaultView;
+ return v === "chat" || v === "interface" ? v : "split";
+}
+
+// appChatReadOnly: the owning app declares its Chat view is a read-only stream
+// (`chatReadOnly: true`) — the app posts into it, the user doesn't. The chat
+// app hides its composer for these threads (like the native embed, where the
+// shell owns the composer). Keyed by thread so chat.js can ask about the one
+// it's rendering.
+export function appChatReadOnly(threadId) {
+ const app = threadOwnerApp(threadId);
+ return !!(app && app.chatReadOnly);
+}
+
// appChatOnly is the third presentation: a companion that is ONLY its chat view
// (chatMessage projections, no Interface, no view toggle). For app-threads that
// are purely a conversation — the setup thread, whose old progress card the
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 9e6c186..cb9fe8a 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -7,8 +7,70 @@
// HOST-OWNED surface: instance settings are real server state, not thread
// events, so everything reads and writes through the host bridge
// (setSetupBridge in shell.js) — the app itself holds no tokens and no fetch
-// calls of its own. Its thread's event log stays empty by design.
+// calls of its own. The one thing that DOES live in the thread's event log is
+// the update stream: release notes the app posts (admin.note), read in the
+// companion Chat view — see announceNew below.
import { defineApp, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
+import { changelogParagraphs } from "../format.js";
+
+// ── Release notes → the Chat view ─────────────────────────────────────────
+// In place of the retired Announcer bot, the Admin app POSTS each new release
+// note into this thread as an ordinary encrypted event; chatMessage projects it
+// as a bubble from "Admin", so updates read as a conversation — the same
+// one-log-two-views model as To-do, with the chat side read-only (chatReadOnly).
+// State is just the ids already posted; the first-ever run baselines silently
+// (records everything current, posts nothing) so a new admin isn't greeted by
+// the whole backlog — exactly the Announcer's first-tick behaviour.
+//
+// Runs once per thread per page load, when the app renders. The check needs
+// state AFTER the history fold, which is exactly when render() first fires.
+//
+// An emit on a closed socket returns false — and right after a reload the
+// Admin thread often renders BEFORE the WebSocket is up (it reopens as the
+// active thread during boot). An idle thread may never render again (no events
+// arrive to trigger one), so a failed send can't wait for the next render: it
+// retries here, briefly, re-reading state each attempt. `posted` guards the
+// same pass re-sending an entry whose echo hasn't folded back yet.
+const announcedFor = new Set();
+async function announceNew(ctx) {
+ if (announcedFor.has(ctx.threadId)) return;
+ announcedFor.add(ctx.threadId);
+ try {
+ const bridge = setupBridge();
+ if (!bridge || !bridge.changelog) return;
+ const entries = await bridge.changelog(); // oldest-first, [] on failure
+ if (!entries.length) return;
+ const posted = new Set();
+ for (let attempt = 0; attempt < 15; attempt++) {
+ if (attempt) await new Promise((r) => setTimeout(r, 2000));
+ // getState() is undefined until the thread's FIRST event folds — and on a
+ // brand-new Admin thread the baseline we're about to post IS that first
+ // event, so guard the empty store.
+ const seen = new Set((ctx.getState() || {}).seen || []);
+ let unsent = false;
+ if (!seen.size && !posted.size) {
+ // First run: baseline silently — record everything current, post nothing.
+ if (await ctx.emit({ type: "admin.baseline", ids: entries.map((e) => e.id) })) return;
+ unsent = true;
+ } else {
+ for (const e of entries) {
+ if (!e.id || seen.has(e.id) || posted.has(e.id)) continue;
+ const text = changelogParagraphs(e.text).join("\n\n");
+ if (!text) continue;
+ if (await ctx.emit({ type: "admin.note", id: e.id, text })) posted.add(e.id);
+ else {
+ unsent = true;
+ break;
+ }
+ }
+ }
+ if (!unsent) return;
+ }
+ announcedFor.delete(ctx.threadId); // still unsent after ~30s — a later render retries
+ } catch {
+ announcedFor.delete(ctx.threadId);
+ }
+}
// /admin works from any chat (like /tutorial): gated on the caller's role, it
// answers in place for non-admins and opens the panel for admins.
@@ -30,8 +92,31 @@ export const app = defineApp({
glyph: "🛠️",
scope: "system",
snapshot: false,
- initialState: () => ({}),
- events: {},
+ // A companion app that leads with its Interface: opening Admin shows the
+ // settings panel (not the split), with the read-only update stream one
+ // Chat-toggle away.
+ defaultView: "interface",
+ chatReadOnly: true,
+ initialState: () => ({ seen: [] }),
+ events: {
+ // One event per release note posted into this thread; `seen` (the posted
+ // ids) is the cross-device watermark deciding what's still new.
+ "admin.note": (s, e) =>
+ typeof e.id === "string" && e.id && !(s.seen || []).includes(e.id) ? { seen: [...(s.seen || []), e.id] } : s,
+ // First-run baseline: mark everything current as seen, posting nothing.
+ "admin.baseline": (s, e) =>
+ Array.isArray(e.ids) ? { seen: [...new Set([...(s.seen || []), ...e.ids.filter((x) => typeof x === "string" && x)])] } : s,
+ },
+
+ // A posted note reads as a message from Admin; the baseline is silent
+ // housekeeping. This hook is also the companion-view opt-in (see apps.js).
+ chatMessage(e) {
+ if (e.type === "admin.note" && e.text) return e.text;
+ return null;
+ },
+
+ // The empty-state intro shown at the top of the Chat view.
+ chatIntro: "Updates about this space are posted here — you'll see the release notes whenever your space updates itself.",
styles: `
.admin-root { max-width: 480px; margin: 0 auto; padding: 28px 20px 48px; }
@@ -115,6 +200,10 @@ export const app = defineApp({
el.appendChild(root);
if (!bridge) return;
+ // Post any release notes that shipped since the last visit into the Chat
+ // view (this thread's read-only stream) — see announceNew below.
+ announceNew(ctx);
+
const section = (title) => {
const s = document.createElement("div");
s.className = "admin-group";
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 4ad2858..2ebd37b 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, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor } from "../apps.js";
+import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly } 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";
@@ -496,6 +496,12 @@ export const app = defineApp({
.chatapp-root.embed .typeahead,
.chatapp-root.embed > .composer-reply,
.chatapp-root.embed > .composer-edit { display: none; }
+/* A read-only chat view (the owning app declares chatReadOnly — the Admin app's
+ notice stream): the app posts here, the user doesn't, so no composer at all. */
+.chatapp-root.readonly > .composer,
+.chatapp-root.readonly .typeahead,
+.chatapp-root.readonly > .composer-reply,
+.chatapp-root.readonly > .composer-edit { display: none; }
/* Embedded: keep the attachment preview visible (a staged photo shows just above
the native composer) and give the feed the native safe-area insets — mirrors
the original feed's .native-embed rules. */
@@ -2501,6 +2507,8 @@ function buildSkeleton(el, ctx) {
};
const inEmbed = !!(host && host.embed);
if (inEmbed) root.classList.add("embed"); // the native shell draws the composer
+ // The owning app declared its chat a read-only stream — no composer to draw.
+ if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly");
const cancelReply = () => {
replyTo = null;
diff --git a/web/static/format.js b/web/static/format.js
index 02ce910..fe4ffe5 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -329,3 +329,19 @@ export function formatBytes(n) {
}
return (v < 10 ? v.toFixed(1) : Math.round(v)) + " " + units[i];
}
+
+// Release notes ship inside the binary as prose that (a) opens with an
+// authorship marker (🤖 / 👨 …) meant for the repo, not readers, and (b) is
+// hard-wrapped at ~72 columns. changelogParagraphs cleans both for display —
+// the Admin app posts each note into its update stream through this: it drops
+// a leading marker, then returns display paragraphs — blank lines split
+// paragraphs, and the single newlines inside one (the hard wraps) collapse
+// back to spaces. Pure, so it's unit-tested.
+export function changelogParagraphs(text) {
+ if (typeof text !== "string") return [];
+ const clean = text.replace(/^\s*(?:🤖|👨|👤|🧑|👩)\s+/u, "");
+ return clean
+ .split(/\n[ \t]*\n/)
+ .map((p) => p.replace(/[ \t]*\n[ \t]*/g, " ").trim())
+ .filter(Boolean);
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 8e596b2..01ed2d9 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -82,6 +82,7 @@ import {
appByName,
appForType,
appWantsChatView,
+ appDefaultView,
appChatOnly,
personalApps,
groupCapableApps,
@@ -2194,7 +2195,7 @@ function paintViewToggle(id) {
toggle.setAttribute("aria-label", "View");
head.insertBefore(toggle, menuWrap);
}
- const view = appThreadView[id] || (companion ? "split" : "chat");
+ const view = appThreadView[id] || (companion ? appDefaultView(owner) : "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;
@@ -2249,7 +2250,7 @@ function paintViewToggle(id) {
// 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] || (threadApp[id] ? "split" : "chat");
+ const cur = appThreadView[id] || (threadApp[id] ? appDefaultView(threadApp[id]) : "chat");
if (cur === view) return;
if (!threadApp[id] && !groupAppsFor(id).length) return;
appThreadView[id] = view;
@@ -3300,6 +3301,21 @@ setSetupBridge({
return false;
}
},
+ // The embedded release notes (changelog), oldest-first as the server stores
+ // them — the Admin app posts new ones into its update stream (admin.note).
+ // Non-secret, non-user-content (ships in the binary, announced publicly —
+ // branding-class), so it reads in the clear; the app just needs the session
+ // token to fetch it (it holds none of its own). Returns [] on any failure.
+ changelog: async () => {
+ try {
+ const r = await fetch(`/api/changelog?token=${encodeURIComponent(session.token)}`);
+ if (!r.ok) return [];
+ const j = await r.json();
+ return Array.isArray(j.entries) ? j.entries : [];
+ } catch {
+ return [];
+ }
+ },
groupInfo: async () => {
try {
const [g, s] = await Promise.all([
@@ -3713,7 +3729,7 @@ function renderAppFor(id) {
// view by default, its interface in the "interface" view.
if (!appWantsChatView(owner)) return owner;
if (appChatOnly(owner)) return "chat";
- return appThreadView[id] === "interface" ? owner : "chat";
+ return (appThreadView[id] || appDefaultView(owner)) === "interface" ? owner : "chat";
}
return chatAppFor(id);
}
@@ -3920,7 +3936,8 @@ function applyAppThreadView(id) {
// 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";
+ if (threadApp[id] && appWantsChatView(threadApp[id]) && !appChatOnly(threadApp[id]))
+ tv.dataset.appView = appThreadView[id] || appDefaultView(threadApp[id]);
else if (!threadApp[id] && chatAppFor(id) && groupAppsFor(id).length) tv.dataset.appView = appThreadView[id] || "chat";
else delete tv.dataset.appView;
}