apps: adding the whiteboard to a chat now opens it (#1035)
Board gates its canvas behind an explicit `open` flag (an empty board is a real starting state, unlike poll's "nothing to show yet") — but every "add this app to the chat" doorway only ever fired groupapps.add, never board.open, so the widget bar/Interface pane showed nothing until someone remembered to type /board. installGroupApp() centralizes "add a shared app to a chat" and, when the app declares an `openEvent`, fires it right after the add lands. Wired into all four doorways: both Apps sheets (legacy + places UI), the /board @here-equivalent auto-add-on-first-use path, and picking apps at group creation. Web-only — native has no board renderer yet, so there's nothing to mirror there.
Co-authored-by: Claude <noreply@anthropic.com>
6 files changed,
+65
−12
changelog/2026-07-15-whiteboard-opens-on-add.md+5 −0test/apps.test.mjs+28 −0web/static/apps.js+19 −7web/static/apps/board.js+6 −0web/static/groupactions.js+4 −3web/static/newchat.js+3 −2
diff --git a/changelog/2026-07-15-whiteboard-opens-on-add.md b/changelog/2026-07-15-whiteboard-opens-on-add.md| new file mode 100644 |
| index 0000000..670f030 |
| --- /dev/null |
| +++ b/changelog/2026-07-15-whiteboard-opens-on-add.md |
| @@ -0,0 +1,5 @@ |
| +🤖 **Adding the whiteboard to a chat now actually shows it.** Sharing the |
| +Whiteboard app into a chat used to add it to the widget bar but leave the |
| +canvas hidden until someone remembered to type `/board` — until then it just |
| +looked broken. Now adding it (from the Apps sheet, from `/board` itself, or |
| +when picking apps for a brand-new group) opens the canvas right away. |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index b56d4a8..8c5b18d 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -48,6 +48,7 @@ import { |
| groupAppsFor, |
| groupCapableApps, |
| appGlyphInto, |
| + installGroupApp, |
| spaceAppsApp, |
| spaceAppsFor, |
| spaceCapableApps, |
| @@ -751,6 +752,33 @@ await test("board: close hides the panel without touching strokes; idempotent", |
| assert.equal(s.strokes.length, 1); |
| }); |
| +await test("installGroupApp adds the app and, for openEvent apps (board), opens it too", async () => { |
| + resetThread("installGroupChat1"); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true))); |
| + assert.equal(await installGroupApp("installGroupChat1", boardApp), true); |
| + assert.deepEqual(sent.map((s) => s.event.type), ["groupapps.add", "board.open"]); |
| + assert.ok(sent.every((s) => s.threadId === "installGroupChat1")); |
| +}); |
| + |
| +await test("installGroupApp is a no-op when the app is already added — no re-add, no re-open", async () => { |
| + resetThread("installGroupChat2"); |
| + foldEvent("installGroupChat2", { type: "groupapps.add", app: "board", name: "Ana" }, { id: 1 }); |
| + assert.ok(groupAppsFor("installGroupChat2").some((a) => a.name === "board")); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| + assert.equal(await installGroupApp("installGroupChat2", boardApp), true); |
| + assert.equal(sent.length, 0, "already added — nothing re-emitted, including no re-open"); |
| +}); |
| + |
| +await test("installGroupApp doesn't fire an openEvent for apps that don't declare one (poll)", async () => { |
| + resetThread("installGroupChat3"); |
| + const sent = []; |
| + setEmitTransport((threadId, event) => (sent.push(event), Promise.resolve(true))); |
| + assert.equal(await installGroupApp("installGroupChat3", pollApp), true); |
| + assert.deepEqual(sent.map((e) => e.type), ["groupapps.add"]); |
| +}); |
| + |
| await test("board: owned types are exactly open/close/stroke/clear", () => { |
| assert.deepEqual(boardApp.types, ["board.open", "board.close", "board.stroke", "board.clear"]); |
| }); |
diff --git a/web/static/apps.js b/web/static/apps.js| index 387fd66..12bbb7b 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -741,6 +741,21 @@ export async function installSpaceApp(app) { |
| return emitAppEvent(tid, { type: "spaceapps.add", app: app.name }); |
| } |
| +// installGroupApp emits the groupapps.add for `app` in `threadId`. Returns |
| +// true when the install event was sent (or the app was already added) — |
| +// shared by both Apps sheets (legacy + places UI) so every doorway that adds |
| +// a shared app to a chat behaves the same. Some apps (board) gate their own |
| +// visible content behind a separate "open" event rather than rendering |
| +// something the moment they're added (see board.js's `openEvent` doc |
| +// comment) — for those, fire that event too, right after the add lands, so |
| +// the app is actually visible instead of silently present but empty. |
| +export async function installGroupApp(threadId, app) { |
| + if (groupAppsFor(threadId).some((a) => a.name === app.name)) return true; |
| + if (!(await emitAppEvent(threadId, { type: "groupapps.add", app: app.name }))) return false; |
| + if (app.openEvent) await emitAppEvent(threadId, { type: app.openEvent }); |
| + return true; |
| +} |
| + |
| // The apps a user could add to a group — feeds the "Add apps" picker. |
| export function groupCapableApps() { |
| return apps.filter((a) => a.places.includes("chat") && a.render); |
| @@ -1687,12 +1702,9 @@ export function runCommand(text, activeThreadId, respond) { |
| reply({ sender, text: `${HERE_FLAG} shares an app with a group — it only works in a group chat.` }); |
| return; |
| } |
| - if (!groupAppsFor(activeThreadId).some((a) => a.name === app.name)) { |
| - const sent = await emitAppEvent(activeThreadId, { type: "groupapps.add", app: app.name }); |
| - if (!sent) { |
| - reply({ sender, text: `Couldn't add ${app.title || app.name} — check your connection and try again.` }); |
| - return; |
| - } |
| + if (!(await installGroupApp(activeThreadId, app))) { |
| + reply({ sender, text: `Couldn't add ${app.title || app.name} — check your connection and try again.` }); |
| + return; |
| } |
| if (!args) return; // just the install — the "added … to this chat" action line is the record |
| // target stays THIS chat: the event goes to the shared instance. |
| @@ -1751,7 +1763,7 @@ export function runCommand(text, activeThreadId, respond) { |
| threadIsGroupFn(activeThreadId) && |
| !groupAppsFor(activeThreadId).some((a) => a.name === app.name) |
| ) { |
| - await emitAppEvent(activeThreadId, { type: "groupapps.add", app: app.name }); |
| + await installGroupApp(activeThreadId, app); |
| } |
| } |
| if (!target) { |
diff --git a/web/static/apps/board.js b/web/static/apps/board.js| index ea5988d..41d390b 100644 |
| --- a/web/static/apps/board.js |
| +++ b/web/static/apps/board.js |
| @@ -53,6 +53,12 @@ export const app = defineApp({ |
| // model; the ambient panel retired with #405). /board in a group adopts it. |
| places: ["chat"], |
| placeNote: "A whiteboard is for sketching together — it lives in a group chat.", |
| + // Unlike poll (which just renders nothing when it has zero polls), an empty |
| + // board is still the useful starting state, so it's gated behind an explicit |
| + // `open` flag instead. `openEvent` tells the Add-apps flow (groupactions.js) |
| + // to fire this the moment the app is added, so the canvas shows up right |
| + // away rather than sitting invisible until someone remembers to run /board. |
| + openEvent: "board.open", |
| initialState: () => ({ open: false, strokes: [] }), |
| events: { |
| "board.open": (s) => (s.open ? s : { ...s, open: true }), |
diff --git a/web/static/groupactions.js b/web/static/groupactions.js| index f53422e..89f5998 100644 |
| --- a/web/static/groupactions.js |
| +++ b/web/static/groupactions.js |
| @@ -10,7 +10,7 @@ import { processImage } from "/static/media.js"; |
| import { avatarFor, threadAvatarFor, paintAvatar, avatarEl, avatarURLs } from "/static/avatars.js"; |
| import { searchIdx } from "/static/searchui.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| -import { emitAppEvent, appGlyphInto, groupAppsFor, listedApps, spaceAppsFor, spaceAppInfo, installSpaceApp, spaceThreadId } from "/static/apps.js"; |
| +import { emitAppEvent, appGlyphInto, groupAppsFor, listedApps, spaceAppsFor, spaceAppInfo, installSpaceApp, installGroupApp, spaceThreadId } from "/static/apps.js"; |
| import { displayNameFor, presenceLabel, renameNotice } from "/static/ui.js"; |
| import { autoGroupTitle } from "/static/format.js"; |
| import { clearThread as storeClearThread } from "/static/store.js"; |
| @@ -1045,7 +1045,8 @@ function manageChatApps(chat) { |
| btn.disabled = true; |
| // Truthy = the event was sent; the echo folds the change for everyone |
| // (the widget bar + action line are the visible record) — done here. |
| - if (await emitAppEvent(tid, { type: added ? "groupapps.remove" : "groupapps.add", app: app.name })) { |
| + const ok = added ? await emitAppEvent(tid, { type: "groupapps.remove", app: app.name }) : await installGroupApp(tid, app); |
| + if (ok) { |
| backdrop.remove(); |
| return; |
| } |
| @@ -1171,7 +1172,7 @@ export function manageGroupApps(opts = {}) { |
| return { |
| ok: true, |
| text: `One shared ${title} for everyone in this chat — and only this chat. Everyone here sees the same copy, beside the conversation.`, |
| - action: { label: "Add to this chat", run: () => emitAppEvent(tid, { type: "groupapps.add", app: app.name }) }, |
| + action: { label: "Add to this chat", run: () => installGroupApp(tid, app) }, |
| }; |
| } |
| // opt === "space" |
diff --git a/web/static/newchat.js b/web/static/newchat.js| index 53c0c46..8de0606 100644 |
| --- a/web/static/newchat.js |
| +++ b/web/static/newchat.js |
| @@ -6,7 +6,7 @@ import { ICON_WRENCH } from "/static/icons.js"; |
| import { session, roster, myUserId, threads, threadKeys, threadApp, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS, ENABLE_MEMBER_APPS, PLACES_UI, instanceKey, ws, tiers, tierKeys, tierKeyB64 } from "/static/shellstate.js"; |
| import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js"; |
| import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js"; |
| -import { appGlyphInto, personalApps, groupCapableApps, emitAppEvent } from "/static/apps.js"; |
| +import { appGlyphInto, personalApps, groupCapableApps, appByName, installGroupApp } from "/static/apps.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| // ---- new chat (roster picker) ------------------------------------------- |
| @@ -494,7 +494,8 @@ const disc = document.createElement("button"); |
| // Birth apps ride the fresh log; best-effort in order — a failed |
| // add is recoverable from ⋮ → Apps, never a failed group. |
| for (const name of birthApps) { |
| - await emitAppEvent(tid, { type: "groupapps.add", app: name }).catch(() => {}); |
| + const app = appByName(name); |
| + if (app) await installGroupApp(tid, app).catch(() => {}); |
| } |
| return tid; |
| })(), |