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>
11 files changed,
+322
−11
changelog/2026-07-09-admin-update-stream.md+4 −0test/apps.test.mjs+28 −2test/event-vectors.test.mjs+42 −0test/event-vectors/admin.json+62 −0test/event-vectors/index.js+2 −0test/format.test.mjs+25 −1web/static/apps.js+21 −0web/static/apps/admin.js+92 −3web/static/apps/chat.js+9 −1web/static/format.js+16 −0web/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; |
| } |