eleven / messenger Public

web+native: App Studio's Open takes each app where it lives (#1406)

App Studio's Open button did one thing for every app: mint a personal
thread and navigate this page to it. That was wrong twice over — a
group-only app got a bogus personal thread it was never meant to have,
and inside a native embed the in-page navigation rendered the opened app
INSIDE App Studio's own window (the pane is one thread's surface there).

Open now routes by the app's places (appOpenPlan in ui.js, node-tested):

- no group place → open straight into where it lives: its personal
  thread, else its space-wide home (installing it first, policy-gated)
- group-only → an inline chooser asks which group chat to add it to
  (the same groupapps.add every Apps sheet emits) and lands there
- group + another place → an inline chooser asks where to open it

The choosers are transient strips under the tapped row — inline, never
floating — in the studio's own style.

In native embeds the personal open now hands off to the native shell
({openAppThread} on the lchat bridge): the Mac routes it through the
palette's existing "app:" open-or-create path, iOS pushes the thread
like a notification tap — so Open opens the app as its own surface
instead of navigating App Studio's webview. Group/space shapes point at
the native Apps sheet until those flows exist natively.

Verified end-to-end with Playwright against a local build: all three
flows, the mixed chooser's group leg, and Cancel.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam c3c1c318cf25cd04006e1385a695759c25c6e760 parent f4d13b4
11 files changed, +312 −6
  • changelog/2026-07-31-appstudio-open-where-it-lives.md +5 −0
  • ios/LChat/LChatApp.swift +15 −0
  • ios/LChat/Views/WebBridge.swift +9 −0
  • ios/LChatCore/Sources/LChatCore/OpenAppThread.swift +13 −0
  • ios/LChatMac/AppThreadWebView.swift +10 −0
  • ios/LChatMac/NativeChatView.swift +8 −0
  • test/ui.test.mjs +17 −0
  • web/static/apps/appstudio.js +153 −2
  • web/static/sandbox.js +24 −2
  • web/static/shell.js +41 −2
  • web/static/ui.js +17 −0
diff --git a/changelog/2026-07-31-appstudio-open-where-it-lives.md b/changelog/2026-07-31-appstudio-open-where-it-lives.md
new file mode 100644
index 0000000..8ddb924
--- /dev/null
+++ b/changelog/2026-07-31-appstudio-open-where-it-lives.md
@@ -0,0 +1,5 @@
+🤖 App Studio's Open button now takes each app to where it actually lives. A
+personal app opens its own thread, as always. An app built for group chats
+asks which group to add it to and lands you there, shared with everyone in
+that chat. And an app that can live in more than one place asks where you'd
+like to open it — just for you, in a group chat, or for the whole space.
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index f9b33b3..fe8fc7c 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -451,6 +451,21 @@ struct RootView: View {
// this fires. (onAppear covers the cold-launch case where it was set first.)
.onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() }
.onChange(of: push.pendingChangelogTap) { _, _ in routeChangelogTap() }
+ // App Studio's Open, forwarded by an app pane's webview bridge
+ // (WebBridge {openAppThread}): open — or create — the app's personal
+ // thread and push it, like a notification tap. The pane itself must
+ // never navigate, or the app renders inside App Studio's screen.
+ .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in
+ guard let store = nav.store,
+ let name = note.userInfo?["app"] as? String, !name.isEmpty else { return }
+ let title = note.userInfo?["title"] as? String ?? name
+ Task {
+ if let id = await store.openOrCreateAppThread(app: name, title: title),
+ path.last != .thread(id) {
+ path = [.chats, .thread(id)]
+ }
+ }
+ }
// Coming back to the app re-fetches the active server's chats, so a chat
// started with us while we were away (thread creation sends no push) is
// there when we look. The `.task(id:)` in ThreadListView only fires on first
diff --git a/ios/LChat/Views/WebBridge.swift b/ios/LChat/Views/WebBridge.swift
index 9ef923c..36b234e 100644
--- a/ios/LChat/Views/WebBridge.swift
+++ b/ios/LChat/Views/WebBridge.swift
@@ -198,5 +198,14 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
// member apps live and told us — re-pull so pickers/routing follow.
AppCatalogPull.pull(from: web, origin: origin, force: true)
}
+ // App Studio's Open, handed off from the embed (shell.js
+ // memberAdmin.openApp): open the app's thread as its own native
+ // surface — this pane must never navigate itself, or the target app
+ // renders inside App Studio's screen. LChatApp consumes it and
+ // pushes the opened-or-created thread.
+ if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String {
+ NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil,
+ userInfo: ["app": app, "title": req["title"] as? String ?? app])
+ }
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/OpenAppThread.swift b/ios/LChatCore/Sources/LChatCore/OpenAppThread.swift
new file mode 100644
index 0000000..99e12f1
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/OpenAppThread.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+// The app-pane webviews' "open this app as its own surface" handoff: App
+// Studio's Open button posts {openAppThread:{app,title}} from an embed
+// (shell.js memberAdmin.openApp) instead of navigating its own page — an
+// in-pane navigation would render the target app inside App Studio's window.
+// Each platform's webview bridge forwards the request here, and whichever
+// surface owns navigation consumes it (Mac: the active NativeChatView via
+// nativeOpenThread's "app:" path; iOS: LChatApp's navigation path), opening
+// or creating the app's personal thread as a real native surface.
+public extension Notification.Name {
+ static let lchatOpenAppThread = Notification.Name("lchatOpenAppThread")
+}
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift
index e547dea..f4d6eea 100644
--- a/ios/LChatMac/AppThreadWebView.swift
+++ b/ios/LChatMac/AppThreadWebView.swift
@@ -207,6 +207,16 @@ final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate,
// re-registered member apps live; re-pull the catalogue.
AppCatalogPull.pull(from: web, origin: origin, force: true)
}
+ // App Studio's Open, handed off from the embed (shell.js
+ // memberAdmin.openApp): open the app's thread as its own native
+ // surface — this pane must never navigate itself, or the target app
+ // renders inside App Studio's window. The active NativeChatView
+ // consumes it through the palette's "app:" open-or-create path.
+ if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String {
+ NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil,
+ userInfo: ["app": app])
+ return
+ }
guard body["ready"] as? Bool == true else { return }
pageReady = true
// A ready page has loaded all three app tiers — pull the live
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index f2df4ee..ea87c0c 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -288,6 +288,14 @@ struct NativeChatView: View {
guard list.indices.contains(next) else { return }
selectedID = list[next].id
}
+ // App Studio's Open, forwarded by an app pane's webview bridge
+ // (MacWebBridge {openAppThread}): route it through the palette's
+ // "app:" open-or-create path below — the pane itself must never
+ // navigate, or the app renders inside App Studio's window.
+ .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in
+ guard isActive, let name = note.userInfo?["app"] as? String, !name.isEmpty else { return }
+ app.nativeOpenThread = "app:" + name
+ }
.onChange(of: app.nativeOpenThread) { _, id in
guard isActive, let id else { return }
// "user:"/"app:" rows have no thread yet — create (the server
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index 68ee131..9fb7d6d 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -24,6 +24,7 @@ import {
DEFAULT_APPEARANCE,
sanitizeActions,
capAllows,
+ appOpenPlan,
normalizeTextSize,
TEXT_SIZES,
DEFAULT_TEXT_SIZE,
@@ -447,5 +448,21 @@ await test("appSchemeFrom: the shipped scheme and dev alts pass, junk falls back
}
});
+await test("appOpenPlan routes App Studio's Open by the app's places", () => {
+ // No group place → open straight into where the app lives.
+ assert.deepEqual(appOpenPlan(["you"]), { ask: false, direct: "you" });
+ assert.deepEqual(appOpenPlan(["you", "space"]), { ask: false, direct: "you" });
+ assert.deepEqual(appOpenPlan(["space"]), { ask: false, direct: "space" });
+ // Group-only → nothing to open until it's added somewhere: ask which group.
+ assert.deepEqual(appOpenPlan(["chat"]), { ask: "group", direct: null });
+ // Group + another place → ask where to open it.
+ assert.deepEqual(appOpenPlan(["you", "chat"]), { ask: "how", direct: "you" });
+ assert.deepEqual(appOpenPlan(["chat", "space"]), { ask: "how", direct: "space" });
+ assert.deepEqual(appOpenPlan(["you", "chat", "space"]), { ask: "how", direct: "you" });
+ // Legacy/empty manifests read as personal-only, like registerMemberApp's default.
+ assert.deepEqual(appOpenPlan(undefined), { ask: false, direct: "you" });
+ assert.deepEqual(appOpenPlan([]), { ask: false, direct: "you" });
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 3466f0b..9e079d0 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -10,9 +10,10 @@
// It's a TRUSTED compiled-in app (it holds the token + instance key to
// encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio
// is just the UI over it. The authoring rules are at GET /api/member-app-guide.
-import { defineApp, namespace } from "../apps.js";
+import { defineApp, namespace, appByName } from "../apps.js";
import { ICON_PUZZLE } from "../icons.js";
import { ENABLE_MEMBER_APPS } from "../shellstate.js";
+import { appOpenPlan } from "../ui.js";
import {
listDeployedApps,
listAppVersions,
@@ -20,6 +21,10 @@ import {
retireMemberApp,
extractManifest,
openMemberApp,
+ memberAppGroups,
+ openMemberAppInGroup,
+ openMemberAppForSpace,
+ memberHostEmbedded,
manifestIsLegacy,
} from "../sandbox.js";
@@ -97,6 +102,142 @@ function el(tag, cls, text) {
return n;
}
+// ---- the place-aware "Open" (appOpenPlan in ui.js) -------------------------
+// Where this app can live. The live registry has the normalized answer
+// (registerMemberApp folds the legacy boolean flags into places); fall back
+// to the stored manifest, normalized the same way, for an app that didn't
+// register this session (e.g. its name collided with another's).
+function placesOf(m) {
+ const reg = appByName(m.name);
+ if (reg && Array.isArray(reg.places) && reg.places.length) return reg.places;
+ return m.places || ["you", ...(m.groupCapable ? ["chat"] : []), ...(m.spaceCapable ? ["space"] : [])];
+}
+
+// The one open chooser — a transient strip under the tapped row (never a
+// floating overlay). Any other Open tap or its Cancel closes it; a re-render
+// wipes the pane, so render() just drops the stale handle.
+let chooser = null;
+function closeChooser() {
+ if (chooser) {
+ chooser.remove();
+ chooser = null;
+ }
+}
+function chooserFor(row, label) {
+ closeChooser();
+ const box = el("div", "chooser");
+ box.setAttribute("role", "group");
+ box.setAttribute("aria-label", label);
+ row.after(box);
+ chooser = box;
+ return box;
+}
+function chooserCancel(box) {
+ const c = el("button", "cancel", "Cancel");
+ c.onclick = closeChooser;
+ box.appendChild(c);
+}
+function chooserNote(row, text) {
+ const box = chooserFor(row, "Open");
+ box.appendChild(el("span", "chooser-note", text));
+ chooserCancel(box);
+ box.querySelector("button").focus();
+}
+
+// "Add to which group chat?" — pick a group, install the shared copy there
+// (the same groupapps.add every Apps sheet emits), and land in that chat.
+function showGroupPicker(m, row) {
+ const title = m.title || m.name;
+ const groups = memberAppGroups();
+ const box = chooserFor(row, `Add ${title} to a group chat`);
+ if (!groups.length) {
+ box.appendChild(el("span", "chooser-note", "This app lives in group chats, and you're not in any yet — start one from + New chat, then add the app there."));
+ chooserCancel(box);
+ box.querySelector("button").focus();
+ return;
+ }
+ box.appendChild(el("span", "chooser-label", "Add to which group chat?"));
+ for (const g of groups) {
+ const b = el("button", null, g.title); // group names are user content — textContent via el()
+ b.onclick = async () => {
+ b.disabled = true;
+ if (await openMemberAppInGroup(m.name, g.id)) {
+ closeChooser();
+ return;
+ }
+ b.disabled = false;
+ box.appendChild(el("span", "chooser-note err", "Couldn't add it there — try again."));
+ };
+ box.appendChild(b);
+ }
+ chooserCancel(box);
+ box.querySelector("button").focus();
+}
+
+// "Open it where?" — for an app that supports group chats AND another place.
+// Options name places, in the Apps sheets' vocabulary; the group option leads
+// to the picker above.
+function showHowChooser(m, row) {
+ const places = placesOf(m);
+ const box = chooserFor(row, `Where to open ${m.title || m.name}`);
+ box.appendChild(el("span", "chooser-label", "Open it where?"));
+ if (places.includes("you")) {
+ const you = el("button", null, "Just for you");
+ you.onclick = () => {
+ closeChooser();
+ openMemberApp(m.name);
+ };
+ box.appendChild(you);
+ }
+ if (places.includes("space")) {
+ const sp = el("button", null, "For the whole space");
+ sp.onclick = async () => {
+ sp.disabled = true;
+ if (await openMemberAppForSpace(m.name)) {
+ closeChooser();
+ return;
+ }
+ sp.disabled = false;
+ box.appendChild(el("span", "chooser-note err", "Couldn't add it for the whole space — that may be admins-only here."));
+ };
+ box.appendChild(sp);
+ }
+ const grp = el("button", null, "In a group chat…");
+ grp.onclick = () => showGroupPicker(m, row);
+ box.appendChild(grp);
+ chooserCancel(box);
+ box.querySelector("button").focus();
+}
+
+// The Open button: route by the app's places. No group place → open straight
+// into where it lives (personal thread, else its space home); group-only →
+// ask which group; group + another place → ask where.
+async function openApp(m, row) {
+ closeChooser();
+ const places = placesOf(m);
+ const plan = appOpenPlan(places);
+ // A native embed is one thread's pane: only the personal open can hand off
+ // to the native shell today, so take it whenever the app supports it
+ // (asking would offer options this pane can't do), and point group/space
+ // shapes at the native Apps sheet instead of opening inside this pane.
+ if (memberHostEmbedded()) {
+ if (places.includes("you")) return openMemberApp(m.name);
+ chooserNote(row, places.includes("chat")
+ ? "This app lives in group chats — open one and add it from that chat's Apps menu."
+ : "This app is for the whole space — add it from a chat's Apps menu.");
+ return;
+ }
+ if (plan.ask === "group") return showGroupPicker(m, row);
+ if (plan.ask === "how") return showHowChooser(m, row);
+ if (plan.direct === "space") {
+ if (!(await openMemberAppForSpace(m.name))) {
+ chooserNote(row, "Couldn't add it for the whole space — that may be admins-only here.");
+ }
+ return;
+ }
+ openMemberApp(m.name);
+}
+
export const app = defineApp({
name: "appstudio",
title: "App Studio",
@@ -149,10 +290,20 @@ export const app = defineApp({
.studio .note.err { color: var(--danger); }
.studio .note.ok { color: var(--accent); }
.studio .spacer { flex: 1 1 auto; }
+/* The Open chooser: an inline strip under the tapped row (occupies layout
+ space, never floats). Wraps like the rows' action cluster on narrow panes. */
+.studio .chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 12px; margin: 8px 0; border-radius: var(--radius); background: var(--bubble-them); }
+.studio .chooser .chooser-label { font-size: 13px; font-weight: 600; }
+.studio .chooser .chooser-note { font-size: 13px; line-height: 1.5; }
+.studio .chooser .chooser-note.err { color: var(--danger); flex: 1 1 100%; }
+.studio .chooser button { cursor: var(--ui-cursor, pointer); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
+.studio .chooser button[disabled] { opacity: .5; cursor: default; }
+.studio .chooser button.cancel { background: none; border-color: transparent; color: var(--muted); }
`,
render(_state, root, ctx) {
root.textContent = "";
+ chooser = null; // the wipe above just removed any open chooser's DOM
const wrap = el("div", "studio");
root.appendChild(wrap);
@@ -302,7 +453,7 @@ export const app = defineApp({
const actions = el("div", "actions");
if (m.name) {
const open = el("button", null, "Open");
- open.onclick = () => openMemberApp(m.name);
+ open.onclick = () => openApp(m, row);
actions.appendChild(open);
}
if (a.mine && a.source) {
diff --git a/web/static/sandbox.js b/web/static/sandbox.js
index bef36a6..9a59571 100644
--- a/web/static/sandbox.js
+++ b/web/static/sandbox.js
@@ -1136,13 +1136,35 @@ export async function deployMemberApp(source, opts = {}) {
return { ok: true, id, updated: updating };
}
-// openMemberApp launches a member app's thread (App Studio's "Open"), via the
-// host opener shell.js injects.
+// openMemberApp launches a member app's personal thread (App Studio's "Open"),
+// via the host opener shell.js injects.
export function openMemberApp(name) {
const admin = memberAdmin && memberAdmin();
if (admin && admin.openApp) return admin.openApp(name);
}
+// The rest of the place-aware "Open" seam (appOpenPlan in ui.js): the group
+// chats an app could be added to, the add-and-land opener for one of them,
+// the space-wide opener, and whether this page is a native embed (where only
+// the personal open can hand off to the native shell today). All thin
+// forwards to the host context — the studio never touches shell state itself.
+export function memberAppGroups() {
+ const admin = memberAdmin && memberAdmin();
+ return admin && admin.groups ? admin.groups() : [];
+}
+export function openMemberAppInGroup(name, threadId) {
+ const admin = memberAdmin && memberAdmin();
+ return admin && admin.openAppInGroup ? admin.openAppInGroup(name, threadId) : false;
+}
+export function openMemberAppForSpace(name) {
+ const admin = memberAdmin && memberAdmin();
+ return admin && admin.openAppForSpace ? admin.openAppForSpace(name) : false;
+}
+export function memberHostEmbedded() {
+ const admin = memberAdmin && memberAdmin();
+ return !!(admin && admin.embedded);
+}
+
// retireMemberApp deletes a member app (author-only, enforced server-side).
export async function retireMemberApp(id) {
const admin = memberAdmin && memberAdmin();
diff --git a/web/static/shell.js b/web/static/shell.js
index 364577d..4b4540d 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -100,6 +100,8 @@ import {
appStateFor,
groupCapableApps,
groupAppsFor,
+ installGroupApp,
+ installSpaceApp,
spaceAppsFor,
setSpaceThread,
setSpaceThreadResolver,
@@ -1022,17 +1024,54 @@ setSurfaceRefresh((tid) => {
if (tid === activeId) renderActiveSurfaces();
});
// App Studio's privileged context: the token + instance key it needs to
-// encrypt/deploy member apps, plus an opener to launch one. An accessor, so it
-// always reads the live instance key (which arrives after boot).
+// encrypt/deploy member apps, plus the openers its place-aware "Open" button
+// routes through (appOpenPlan in ui.js). An accessor, so it always reads the
+// live instance key (which arrives after boot).
setMemberAdmin(() => ({
token: session.token,
instanceKey,
uid: myUserId,
name: session.name,
+ // In a native embed this page IS one thread's pane — navigating it here
+ // would render the app inside App Studio's own window. Flagged so the
+ // studio offers only what works, and openApp hands the gesture to the
+ // native shell instead ({openAppThread}), which opens — or creates — the
+ // app's thread as its own native surface.
+ embedded: !!embed,
openApp: async (name) => {
+ if (embed) {
+ nativePost({ openAppThread: { app: name, title: (appByName(name) || {}).title || name } });
+ return;
+ }
const id = await resolveAppThread(name);
if (id) openThread(id);
},
+ // The group chats this member could add an app to (the studio's "which
+ // group?" picker). Groups whose key hasn't arrived can't take the install
+ // event yet, so they're left out rather than offered and failed.
+ groups: () =>
+ threads
+ .filter((t) => t.kind === "group" && threadKeys[t.id])
+ .map((t) => ({ id: t.id, title: threadName[t.id] || "Group" })),
+ // Add a shared copy to a group chat and land there — the same groupapps.add
+ // every Apps sheet emits, so the add is an event in the group's own log.
+ openAppInGroup: async (name, tid) => {
+ const app = appByName(name);
+ if (!app || !threadKeys[tid]) return false;
+ if (!(await installGroupApp(tid, app))) return false;
+ openThread(tid);
+ return true;
+ },
+ // Open the space-wide copy (its sidebar home), installing it first when it
+ // isn't there yet. installSpaceApp enforces the install policy — false
+ // means "admins only" here, and the studio says so instead of half-opening.
+ openAppForSpace: async (name) => {
+ const app = appByName(name);
+ if (!app || !app.render) return false;
+ if (!spaceAppsFor().some((a) => a.name === name) && !(await installSpaceApp(app))) return false;
+ openSpaceApp(name);
+ return true;
+ },
}));
async function loadMemberAppsNow() {
diff --git a/web/static/ui.js b/web/static/ui.js
index bce1233..da4126c 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -402,6 +402,23 @@ export function capAllows(cap, access) {
return cap !== "read"; // writes need a read+write (or legacy, cap-less) connection
}
+// appOpenPlan: what App Studio's "Open" should do for an app with these
+// places (docs/member-apps.md). An app that can't live in a group chat opens
+// straight into where it does live — a personal thread when "you" is among
+// its places, else its space-wide home. One that ONLY lives in group chats
+// has nothing to open until it's added to one, so the button asks which
+// group; one that supports group chats AND another place asks how to open it.
+// Pure, so the routing rule is one testable place.
+// → { ask: false | "group" | "how", direct: "you" | "space" | null }
+// `direct` is the non-group place a direct open (or the how-chooser's
+// non-group option) targets; null only for group-only apps.
+export function appOpenPlan(places) {
+ const p = Array.isArray(places) && places.length ? places : ["you"];
+ const direct = p.includes("you") ? "you" : p.includes("space") ? "space" : null;
+ if (!p.includes("chat")) return { ask: false, direct };
+ return { ask: direct ? "how" : "group", direct };
+}
+
// sanitizeActions coerces an untrusted payload `actions` array to renderable
// chips: whitelist the type, validate the param (plus the type's own sanitize
// hook, when it has one), cap the count and label size.