eleven / ocho Public

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>
James Adam ae305ffbf20fab45129553100a1685d4d44cedbd parent 3f44816
6 files changed, +65 −12
  • changelog/2026-07-15-whiteboard-opens-on-add.md +5 −0
  • test/apps.test.mjs +28 −0
  • web/static/apps.js +19 −7
  • web/static/apps/board.js +6 −0
  • web/static/groupactions.js +4 −3
  • web/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;
})(),