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>
11 files changed,
+312
−6
changelog/2026-07-31-appstudio-open-where-it-lives.md+5 −0ios/LChat/LChatApp.swift+15 −0ios/LChat/Views/WebBridge.swift+9 −0ios/LChatCore/Sources/LChatCore/OpenAppThread.swift+13 −0ios/LChatMac/AppThreadWebView.swift+10 −0ios/LChatMac/NativeChatView.swift+8 −0test/ui.test.mjs+17 −0web/static/apps/appstudio.js+153 −2web/static/sandbox.js+24 −2web/static/shell.js+41 −2web/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. |