native+web: space routing in the host + the space band (app-host phase 4) (#1316)
Phase 4 of docs/native-apps.md. Acceptance: /gif (a space app) works
natively where installed; space installs get native rows.
- The embed space-thread bail lifts: loadSpaceThread now runs wherever the
INSTANCE KEY is present — a command host seeded by the native shell
learns the space thread (unwrap open_key, seed installs, catch up), so
space-installed apps' commands route there instead of answering "isn't
available here yet". bootEmbed fires it non-blocking ({ready} never
waits); keyless embeds and pre-bridge servers behave exactly as before.
- A space app's SURFACE renders through the web's own path: a `space:true`
embed seed (the seed's thread IS the space thread) makes bootEmbed set
spaceThreadId, fold the space state, and call openSpaceApp(app) — never
the chat fallback the dm-stub row used to mount.
- Native space band, both platforms: Mac sidebar rows (SPACE section,
selection "space:<app>" → full-pane AppWebSurface) and iOS chips (a
bottom safe-area band on the chat list → Route.spaceApp → SpaceAppScreen)
— both fed by SpaceAppsStore × the registry (knownSpace + surface).
- SpaceAppsStore exposes (and parks) the space thread's key b64 and mints
the synthetic surface ChatThread (the memberwise init is internal);
webview caches key space surfaces per app (a space app is fixed at page
boot — two apps must not share a page).
- appCatalogReport() gains `surface` (does the app have a render?) so
command-only apps (gif) get no band row anywhere — the web's own
spaceAppsFor filter, answered in the manifest; seed regenerated.
swift test: 362 pass; npm test green (surface pinned); Mac + iOS build.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+347
−32
docs/native-apps.md+8 −1ios/LChat/FeedWebCache.swift+18 −9ios/LChat/LChatApp.swift+7 −0ios/LChat/Views/SpaceAppScreen.swift+92 −0ios/LChat/Views/ThreadListView.swift+10 −0ios/LChatCore/Sources/LChatCore/AppCatalog.swift+5 −0ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json+23 −0ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift+31 −4ios/LChatMac/AppThreadWebView.swift+28 −13ios/LChatMac/NativeChatView.swift+95 −1test/appcatalog.test.mjs+1 −1web/static/apps.js+3 −0web/static/shell.js+26 −3
diff --git a/docs/native-apps.md b/docs/native-apps.md| index 66082aa..da24aba 100644 |
| --- a/docs/native-apps.md |
| +++ b/docs/native-apps.md |
| @@ -1,6 +1,13 @@ |
| # 🤖 Native apps × the app platform: apps as first-class citizens |
| -> Status: **DESIGN + PHASES 0–3 (2026-07-22)** — phase 3 (branch |
| +> Status: **DESIGN + PHASES 0–4 (2026-07-22)** — phase 4 (branch |
| +> `claude/native-apps-space`) ships space routing + the space band: the |
| +> embed space-thread bail lifts when the native shell seeded the instance |
| +> key (space-installed apps' commands — /gif — route in the command host), |
| +> a `space:true` embed seed renders a space app's surface through the web's |
| +> own openSpaceApp path, and both platforms grow a space band (Mac sidebar |
| +> rows / iOS chips) from SpaceAppsStore × the registry — the manifest gains |
| +> `surface` (has a render) so command-only apps get no row. Phase 3 (branch |
| > `claude/native-apps-commands`) ships commands everywhere: the command host |
| > moves to LChatCore and iOS gains it (panel + notices in the native |
| > composer), both platforms forward every non-floor slash command |
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index 748f64c..08df33a 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -72,9 +72,10 @@ final class FeedWebCache { |
| // interface instead of the chat feed, and caches separately from the feed |
| // (same thread, different surface). |
| func webView(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| - instanceKeyB64: String?, app: String? = nil) -> WKWebView { |
| + instanceKeyB64: String?, app: String? = nil, |
| + space: Bool = false) -> WKWebView { |
| let e = entry(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app) |
| + instanceKeyB64: instanceKeyB64, app: app, space: space) |
| e.lastUsed = Date() |
| return e.web |
| } |
| @@ -99,17 +100,21 @@ final class FeedWebCache { |
| private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat") -> Entry { |
| + defaultView: String = "chat", space: Bool = false) -> Entry { |
| // One shared pane per group for ALL its apps (switching apps is a JS |
| // call into the same page — WebBridge.setGroupApp — not a reload), so |
| - // the key marks the surface, not the app. |
| - let key = instance.id + "|" + thread.id + (app != nil ? "|groupapps" : "") |
| + // the key marks the surface, not the app — EXCEPT space surfaces: |
| + // a space app is fixed at page boot (openSpaceApp), so each keeps |
| + // its own page. |
| + let key = instance.id + "|" + thread.id |
| + + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) |
| // Reuse only if the page was seeded with the same thread key — a re-invite / |
| // key change makes the parked page stale, so rebuild it. |
| if let e = entries[key], e.seedKey == thread.threadKeyB64 { return e } |
| if let e = entries.removeValue(forKey: key) { release(e) } |
| let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView) |
| + instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView, |
| + space: space) |
| entries[key] = e |
| evictIfNeeded() |
| return e |
| @@ -118,7 +123,7 @@ final class FeedWebCache { |
| // The webview construction that used to live in ChatFeedWebView.makeUIView. |
| private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat") -> Entry { |
| + defaultView: String = "chat", space: Bool = false) -> Entry { |
| let bridge = WebBridge() |
| bridge.origin = instance.origin // the app-catalogue pull's registry key |
| let cfg = WKWebViewConfiguration() |
| @@ -126,7 +131,7 @@ final class FeedWebCache { |
| cfg.allowsInlineMediaPlayback = true |
| if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId, |
| instanceKeyB64: instanceKeyB64, app: app, |
| - defaultView: defaultView) { |
| + defaultView: defaultView, space: space) { |
| cfg.userContentController.addUserScript(script) |
| } |
| cfg.userContentController.add(bridge, name: "lchat") |
| @@ -156,7 +161,7 @@ final class FeedWebCache { |
| // (AppViewMemory lives below so the seed and ChatView's toggle share one truth.) |
| private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat") -> WKUserScript? { |
| + defaultView: String = "chat", space: Bool = false) -> WKUserScript? { |
| var seed: [String: Any] = [ |
| "token": instance.deviceToken, |
| "name": instance.screenName, |
| @@ -176,6 +181,10 @@ final class FeedWebCache { |
| seed["app"] = app |
| seed["view"] = "interface" |
| } |
| + // A SPACE app's surface (docs/native-apps.md phase 4): the seed's |
| + // thread IS the space thread — bootEmbed folds its installs and opens |
| + // the app via the web's own openSpaceApp path (never a chat). |
| + if space { seed["space"] = true } |
| if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| // Per-viewer names (#577): the resolved uid → name map, so the page |
| // shows my names for people. Names only — never key material (the |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 165e8c5..e1684b9 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -325,6 +325,7 @@ final class NavModel: ObservableObject { |
| enum Route: Hashable { |
| case chats |
| case thread(String) |
| + case spaceApp(String) // a space-wide app's full-pane surface (docs/native-apps.md phase 4) |
| } |
| // The shell, the Mimestream shape: the stack's ROOT is the spaces home screen |
| @@ -382,6 +383,12 @@ struct RootView: View { |
| } else { |
| ProgressView() |
| } |
| + case .spaceApp(let app): |
| + if let store = nav.store { |
| + SpaceAppScreen(store: store, app: app) |
| + } else { |
| + ProgressView() |
| + } |
| } |
| } |
| } |
diff --git a/ios/LChat/Views/SpaceAppScreen.swift b/ios/LChat/Views/SpaceAppScreen.swift| new file mode 100644 |
| index 0000000..8a6be75 |
| --- /dev/null |
| +++ b/ios/LChat/Views/SpaceAppScreen.swift |
| @@ -0,0 +1,92 @@ |
| +import LChatCore |
| +import SwiftUI |
| +import WebKit |
| + |
| +// 🤖 A space-wide app's full-pane surface on iOS (docs/native-apps.md phase |
| +// 4): the webview seeded with the SPACE thread + its key, wearing the app's |
| +// identity — bootEmbed's space flag routes it through the web's own |
| +// openSpaceApp path (the space thread has no chat surface). Pushed from the |
| +// chat list's space band. |
| +struct SpaceAppScreen: View { |
| + let store: ThreadStore |
| + let app: String |
| + |
| + var body: some View { |
| + Group { |
| + if let thread = store.spaceApps.surfaceThread( |
| + app: app, title: store.registry.title(app), glyph: store.registry.glyph(app)) { |
| + SpaceAppWebView(instance: store.instance, thread: thread, |
| + myUserId: store.myUserId, |
| + instanceKeyB64: store.instanceKey(), app: app) |
| + .ignoresSafeArea(edges: .bottom) |
| + } else { |
| + ContentUnavailableView("This app isn't ready yet", |
| + systemImage: "square.grid.2x2", |
| + description: Text("Try again in a moment.")) |
| + } |
| + } |
| + .navigationTitle(store.registry.title(app)) |
| + .navigationBarTitleDisplayMode(.inline) |
| + } |
| +} |
| + |
| +private struct SpaceAppWebView: UIViewRepresentable { |
| + let instance: SavedInstance |
| + let thread: ChatThread |
| + let myUserId: String |
| + let instanceKeyB64: String? |
| + let app: String |
| + |
| + func makeUIView(context: Context) -> WKWebView { |
| + FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, app: app, space: true) |
| + } |
| + |
| + func updateUIView(_ web: WKWebView, context: Context) {} |
| +} |
| + |
| +// 🤖 The chat list's SPACE BAND: one chip per space-wide install with an |
| +// openable surface — the iOS shape of the web sidebar's space rows (the |
| +// UIKit-backed thread list can't grow SwiftUI rows, so the band rides a |
| +// safe-area inset: in the layout, never floating over rows someone is |
| +// reading). Its own view so it observes SpaceAppsStore and AppRegistry |
| +// directly. |
| +struct SpaceBand: View { |
| + @ObservedObject var spaceApps: SpaceAppsStore |
| + @ObservedObject var registry: AppRegistry |
| + let onOpen: (String) -> Void |
| + |
| + private var apps: [AppManifest] { |
| + registry.knownSpace(spaceApps.installs) |
| + .compactMap { registry.manifest($0.id) } |
| + .filter { $0.surface ?? true } |
| + } |
| + |
| + var body: some View { |
| + if !apps.isEmpty { |
| + ScrollView(.horizontal, showsIndicators: false) { |
| + HStack(spacing: 8) { |
| + ForEach(apps) { app in |
| + Button { onOpen(app.name) } label: { |
| + HStack(spacing: 6) { |
| + Text(app.glyph).font(.system(size: 15)) |
| + Text(app.title) |
| + .font(.subheadline.weight(.medium)) |
| + .lineLimit(1) |
| + } |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 7) |
| + .floatingGlass(in: Capsule()) |
| + .contentShape(Capsule()) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("\(app.title), space app") |
| + .accessibilityHint("Opens the app shared with everyone in this space") |
| + } |
| + } |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 8) |
| + } |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index fc3d9d8..fb7a591 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -106,6 +106,16 @@ struct ThreadListView: View { |
| if filtering { filterChip } |
| } |
| } |
| + // The SPACE BAND (docs/native-apps.md phase 4): space-wide installs, |
| + // folded from the space thread and filtered through the registry — |
| + // in-layout via the inset, tucked above the home indicator. |
| + .safeAreaInset(edge: .bottom, spacing: 0) { |
| + if search.trimmingCharacters(in: .whitespaces).isEmpty { |
| + SpaceBand(spaceApps: store.spaceApps, registry: store.registry) { app in |
| + path.append(.spaceApp(app)) |
| + } |
| + } |
| + } |
| .searchable(text: $search, prompt: "Search chats") |
| // A fresh member lands straight in Eleven's welcome conversation (the |
| // web's maybeStartWelcome, natively): open the welcome app-thread on |
diff --git a/ios/LChatCore/Sources/LChatCore/AppCatalog.swift b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift| index f66527c..b0e755d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/AppCatalog.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift |
| @@ -54,6 +54,11 @@ public struct AppManifest: Codable, Equatable, Sendable, Identifiable { |
| /// rule). Optional on the wire: reports from earlier bridge versions |
| /// don't carry it, and missing must read as ungated (their behaviour). |
| public var requiresService: String? |
| + /// Whether the app has an openable surface (a render) — command-only |
| + /// apps (gif) get no sidebar/space-band row. Optional on the wire: |
| + /// reports from earlier bridge versions don't carry it; treat missing |
| + /// as "has one" (the safer default for rows). |
| + public var surface: Bool? |
| // The presentation contract, pre-answered by the reporting page (the same |
| // rules as appWantsChatView / appChatOnly / appDefaultView) so native |
| // never re-derives it: companion = has a Chat view; chatOnly = ONLY a |
diff --git a/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json| index 55d3905..6c56d16 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json |
| @@ -12,6 +12,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": true, |
| "chatOnly": false, |
| "chatReadOnly": true, |
| @@ -34,6 +35,7 @@ |
| "mark": "", |
| "openEvent": "board.open", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -64,6 +66,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -91,6 +94,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -113,6 +117,7 @@ |
| "mark": "plug", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -142,6 +147,7 @@ |
| "mark": "dnsimple", |
| "openEvent": "", |
| "requiresService": "dnsimple", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -162,6 +168,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -191,6 +198,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -220,6 +228,7 @@ |
| "mark": "github", |
| "openEvent": "", |
| "requiresService": "github", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -242,6 +251,7 @@ |
| "mark": "hubspot", |
| "openEvent": "", |
| "requiresService": "hubspot", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -262,6 +272,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -284,6 +295,7 @@ |
| "mark": "linear", |
| "openEvent": "", |
| "requiresService": "linear", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -304,6 +316,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -333,6 +346,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -363,6 +377,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": true, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -390,6 +405,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": true, |
| "chatOnly": true, |
| "chatReadOnly": false, |
| @@ -412,6 +428,7 @@ |
| "mark": "stripe", |
| "openEvent": "", |
| "requiresService": "stripe", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -435,6 +452,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": true, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -471,6 +489,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -498,6 +517,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -529,6 +549,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": true, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -556,6 +577,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": true, |
| "companion": false, |
| "chatOnly": false, |
| "chatReadOnly": false, |
| @@ -576,6 +598,7 @@ |
| "mark": "", |
| "openEvent": "", |
| "requiresService": "", |
| + "surface": false, |
| "companion": true, |
| "chatOnly": true, |
| "chatReadOnly": false, |
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift b/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift| index f9e0121..327919f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift |
| @@ -22,6 +22,10 @@ public final class SpaceAppsStore: ObservableObject { |
| /// route the thread's live frames here. nil until fetched (or when the |
| /// space has no thread yet). |
| public private(set) var threadID: String? |
| + /// The space thread's AES key (base64url), for seeding a space app's |
| + /// webview surface — the same narrow-key handoff every app webview gets |
| + /// (never the instance key, never the private key). nil until unwrapped. |
| + public private(set) var threadKeyB64: String? |
| private let instance: SavedInstance |
| private var key: GroupKey? |
| @@ -62,6 +66,7 @@ public final class SpaceAppsStore: ObservableObject { |
| guard let id = st.id else { |
| threadID = nil |
| key = nil |
| + threadKeyB64 = nil |
| seed = [] |
| upTo = 0 |
| events = [:] |
| @@ -75,11 +80,14 @@ public final class SpaceAppsStore: ObservableObject { |
| // open_key is the thread's AES key under the instance key, the |
| // open-group envelope ({k}) — mirrors loadSpaceThread in shell.js. |
| let openKey = st.openKey |
| - key = await Task.detached(priority: .userInitiated) { |
| + let unwrapped: (GroupKey, String)? = await Task.detached(priority: .userInitiated) { |
| guard let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: openKey), |
| - let raw = Data(base64urlEncoded: payload.k) else { return nil } |
| - return try? GroupKey(rawKey: raw) |
| + let raw = Data(base64urlEncoded: payload.k), |
| + let k = try? GroupKey(rawKey: raw) else { return nil } |
| + return (k, payload.k) |
| }.value |
| + key = unwrapped?.0 |
| + threadKeyB64 = unwrapped?.1 |
| } |
| // No instance key on this device (not granted yet): the thread exists |
| // but we can't read it — keep the parked list and try again next prod. |
| @@ -144,7 +152,7 @@ public final class SpaceAppsStore: ObservableObject { |
| // nothing changed (recompute refires on every live frame). |
| guard changed || !hydrated else { return } |
| hydrated = true |
| - let snap = Snap(threadID: threadID, installs: folded) |
| + let snap = Snap(threadID: threadID, threadKeyB64: threadKeyB64, installs: folded) |
| let origin = instance.origin |
| Task.detached(priority: .utility) { |
| SpaceAppsSnapshot.save(origin: origin, snap: snap) |
| @@ -163,14 +171,33 @@ public final class SpaceAppsStore: ObservableObject { |
| }).value else { return } |
| guard !loaded, installs.isEmpty else { return } // a pass raced us — it wins |
| threadID = threadID ?? snap.threadID |
| + if threadKeyB64 == nil, let parked = snap.threadKeyB64 { |
| + threadKeyB64 = parked |
| + key = try? GroupKey(base64url: parked) |
| + } |
| installs = snap.installs |
| } |
| + /// A synthetic ChatThread for a space app's webview surface — the space |
| + /// thread + its key wearing the app's identity, shaped for the existing |
| + /// app-pane machinery. Never listed in ThreadStore.threads; nil until |
| + /// the space thread has been fetched and its key unwrapped. |
| + public func surfaceThread(app: String, title: String, glyph: String) -> ChatThread? { |
| + guard let id = threadID, let keyB64 = threadKeyB64 else { return nil } |
| + return ChatThread(id: id, kind: "space", title: title, preview: "", |
| + lastAt: 0, unread: false, pinned: false, hidden: false, |
| + muted: nil, members: [], threadKeyB64: keyB64, avatar: nil, |
| + isApp: true, appId: app, glyph: glyph, badge: "space") |
| + } |
| + |
| /// The parked fold, one per origin. Keychain, not a file: installed app |
| /// names (and who installed them) are decrypted user content — the |
| /// ThreadListSnapshot posture. |
| public struct Snap: Codable, Sendable { |
| public var threadID: String? |
| + /// Optional: snaps parked before phase 4 don't carry it. Keychain, |
| + /// so parking the key matches the SharedThreadKeys posture. |
| + public var threadKeyB64: String? |
| public var installs: [SpaceApps.Item] |
| } |
| } |
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift| index fcb9a31..32baf2b 100644 |
| --- a/ios/LChatMac/AppThreadWebView.swift |
| +++ b/ios/LChatMac/AppThreadWebView.swift |
| @@ -19,6 +19,7 @@ struct AppThreadWebView: NSViewRepresentable { |
| let myUserId: String |
| let instanceKeyB64: String? |
| var app: String? = nil |
| + var space: Bool = false |
| // Hands out the CACHED page — a detached WKWebView keeps its page, so |
| // toggling Chat | Both | View reattaches the same live app instead of |
| @@ -26,7 +27,7 @@ struct AppThreadWebView: NSViewRepresentable { |
| func makeNSView(context: Context) -> WKWebView { |
| AppWebViewCache.shared.webView(for: instance, thread: thread, |
| myUserId: myUserId, instanceKeyB64: instanceKeyB64, |
| - app: app) |
| + app: app, space: space) |
| } |
| func updateNSView(_ web: WKWebView, context: Context) {} |
| @@ -35,7 +36,7 @@ struct AppThreadWebView: NSViewRepresentable { |
| // mode and carries the seed. |
| fileprivate static func bootScript(instance: SavedInstance, thread: ChatThread, |
| myUserId: String, instanceKeyB64: String?, |
| - app: String?) -> WKUserScript? { |
| + app: String?, space: Bool = false) -> WKUserScript? { |
| var seed: [String: Any] = [ |
| "token": instance.deviceToken, |
| "name": instance.screenName, |
| @@ -52,6 +53,11 @@ struct AppThreadWebView: NSViewRepresentable { |
| seed["view"] = "interface" |
| // A group pane also names which added app to show (shell.js bootEmbed). |
| if let app { seed["app"] = app } |
| + // A SPACE app's surface (docs/native-apps.md phase 4): the seed's |
| + // thread IS the space thread — bootEmbed folds its installs and opens |
| + // the app via the web's own openSpaceApp path (never a chat; without |
| + // the flag the page would mount the conversation feed). |
| + if space { seed["space"] = true } |
| guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| let json = String(data: data, encoding: .utf8) else { return nil } |
| return WKUserScript(source: "window.__lchatEmbed = \(json);", |
| @@ -71,24 +77,27 @@ struct AppWebSurface: View { |
| let myUserId: String |
| let instanceKeyB64: String? |
| var app: String? = nil |
| + var space: Bool = false |
| @MainActor |
| init(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| - instanceKeyB64: String?, app: String? = nil) { |
| + instanceKeyB64: String?, app: String? = nil, space: Bool = false) { |
| self.instance = instance |
| self.thread = thread |
| self.myUserId = myUserId |
| self.instanceKeyB64 = instanceKeyB64 |
| self.app = app |
| + self.space = space |
| bridge = AppWebViewCache.shared.bridge(for: instance, thread: thread, |
| myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app) |
| + instanceKeyB64: instanceKeyB64, app: app, |
| + space: space) |
| } |
| var body: some View { |
| ZStack { |
| AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app) |
| + instanceKeyB64: instanceKeyB64, app: app, space: space) |
| if !bridge.pageReady { |
| WebLoadingVeil().transition(.opacity) |
| } |
| @@ -210,22 +219,28 @@ final class AppWebViewCache { |
| private let maxEntries = 6 |
| func webView(for instance: SavedInstance, thread: ChatThread, |
| - myUserId: String, instanceKeyB64: String?, app: String? = nil) -> WKWebView { |
| + myUserId: String, instanceKeyB64: String?, app: String? = nil, |
| + space: Bool = false) -> WKWebView { |
| entry(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app).web |
| + instanceKeyB64: instanceKeyB64, app: app, space: space).web |
| } |
| func bridge(for instance: SavedInstance, thread: ChatThread, |
| - myUserId: String, instanceKeyB64: String?, app: String? = nil) -> MacWebBridge { |
| + myUserId: String, instanceKeyB64: String?, app: String? = nil, |
| + space: Bool = false) -> MacWebBridge { |
| entry(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app).bridge |
| + instanceKeyB64: instanceKeyB64, app: app, space: space).bridge |
| } |
| private func entry(for instance: SavedInstance, thread: ChatThread, |
| - myUserId: String, instanceKeyB64: String?, app: String?) -> Entry { |
| + myUserId: String, instanceKeyB64: String?, app: String?, |
| + space: Bool = false) -> Entry { |
| // One shared pane per group for ALL its apps (switching is a JS call, |
| - // not a reload), so the key marks the surface, not the app. |
| - let key = instance.id + "|" + thread.id + (app != nil ? "|groupapps" : "") |
| + // not a reload), so the key marks the surface, not the app — EXCEPT |
| + // space surfaces: a space app is fixed at page boot (openSpaceApp), |
| + // so each one keeps its own page. |
| + let key = instance.id + "|" + thread.id |
| + + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) |
| // Reuse only if seeded with the same thread key — a re-wrap = stale page. |
| if var e = entries[key], e.seedKey == thread.threadKeyB64 { |
| e.lastUsed = Date() |
| @@ -240,7 +255,7 @@ final class AppWebViewCache { |
| if let script = AppThreadWebView.bootScript(instance: instance, thread: thread, |
| myUserId: myUserId, |
| instanceKeyB64: instanceKeyB64, |
| - app: app) { |
| + app: app, space: space) { |
| cfg.userContentController.addUserScript(script) |
| } |
| cfg.userContentController.add(bridge, name: "lchat") |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 672edb5..aab61a5 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -939,7 +939,9 @@ struct NativeChatView: View { |
| } |
| @ViewBuilder private var feed: some View { |
| - if let thread = selectedThread, isCompanion(thread) { |
| + if let sel = selectedID, sel.hasPrefix("space:") { |
| + spaceAppPane(String(sel.dropFirst(6))) |
| + } else if let thread = selectedThread, isCompanion(thread) { |
| companionSplit(thread) |
| } else if let thread = selectedThread, |
| thread.appId == "setup" || thread.appId == "welcome" { |
| @@ -967,6 +969,23 @@ struct NativeChatView: View { |
| } |
| } |
| + // A space app rendered full-pane (the web's openSpaceApp): the webview |
| + // seeded with the SPACE thread + its key, wearing the app's identity — |
| + // bootEmbed's space flag routes it through the web's own space pane. |
| + @ViewBuilder private func spaceAppPane(_ app: String) -> some View { |
| + if let thread = store.spaceApps.surfaceThread( |
| + app: app, title: store.registry.title(app), glyph: store.registry.glyph(app)) { |
| + AppWebSurface(instance: instance, thread: thread, myUserId: store.myUserId, |
| + instanceKeyB64: store.instanceKey(), app: app, space: true) |
| + .id("space|" + thread.id + "|" + app) |
| + } else { |
| + Text("This app isn't ready yet — try again in a moment.") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + } |
| + } |
| + |
| // ---- the sidebar header (the web's side-head: name, ⋮, new chat, search) |
| private var myAvatarSource: AvatarSource? { |
| @@ -1364,6 +1383,18 @@ struct NativeChatView: View { |
| content: { AnyView(threadRow(thread)) }) |
| .equatable() |
| } |
| + // The SPACE BAND (docs/native-apps.md phase 4): one row per |
| + // space-wide install with an openable surface — the web |
| + // sidebar's space rows, folded from the space thread |
| + // (SpaceAppsStore) and filtered through the registry. |
| + if search.trimmingCharacters(in: .whitespaces).isEmpty, |
| + (!filtering || filterApps), !filterOnline { |
| + SpaceBandSection(spaceApps: store.spaceApps, |
| + registry: store.registry, |
| + selectedID: selectedID) { app in |
| + selectedID = "space:" + app |
| + } |
| + } |
| // The funnel emptied the list — say so instead of presenting |
| // a silently blank pane. (Search's empty case is the panel's.) |
| if visibleThreads.isEmpty, filtering, |
| @@ -1866,3 +1897,66 @@ struct RailRowButtonStyle: ButtonStyle { |
| } |
| } |
| } |
| + |
| +// 🤖 The sidebar's SPACE BAND (docs/native-apps.md phase 4): one row per |
| +// space-wide install with an openable surface — the native twin of the web's |
| +// space rows. Its own view so it observes the SpaceAppsStore and AppRegistry |
| +// directly (ThreadStore doesn't republish their changes). |
| +private struct SpaceBandSection: View { |
| + @ObservedObject var spaceApps: SpaceAppsStore |
| + @ObservedObject var registry: AppRegistry |
| + let selectedID: String? |
| + let onOpen: (String) -> Void |
| + |
| + private var apps: [AppManifest] { |
| + registry.knownSpace(spaceApps.installs) |
| + .compactMap { registry.manifest($0.id) } |
| + .filter { $0.surface ?? true } |
| + } |
| + |
| + var body: some View { |
| + if !apps.isEmpty { |
| + Text("SPACE") |
| + .font(.system(size: 10, weight: .semibold)) |
| + .kerning(0.5) |
| + .foregroundStyle(.tertiary) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(EdgeInsets(top: 10, leading: 10, bottom: 2, trailing: 0)) |
| + .accessibilityAddTraits(.isHeader) |
| + ForEach(apps) { app in |
| + row(app) |
| + } |
| + } |
| + } |
| + |
| + private func row(_ app: AppManifest) -> some View { |
| + let selected = selectedID == "space:" + app.name |
| + return Button { onOpen(app.name) } label: { |
| + HStack(spacing: 8) { |
| + Text(app.glyph) |
| + .font(.system(size: 15)) |
| + .frame(width: 30, height: 30) |
| + .background(Circle().fill(.primary.opacity(0.06))) |
| + Text(app.title) |
| + .font(.system(size: 13, weight: .medium)) |
| + .lineLimit(1) |
| + Spacer() |
| + Text("space") |
| + .font(.system(size: 9, weight: .semibold)) |
| + .foregroundStyle(.secondary) |
| + .padding(.horizontal, 5) |
| + .padding(.vertical, 1) |
| + .background(Capsule().fill(.primary.opacity(0.08))) |
| + } |
| + .padding(.horizontal, 8) |
| + .padding(.vertical, 5) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| + .fill(selected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear))) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("\(app.title), space app") |
| + .accessibilityHint("Opens the app shared with everyone in this space") |
| + } |
| +} |
diff --git a/test/appcatalog.test.mjs b/test/appcatalog.test.mjs| index 44f7cb3..ba3d431 100644 |
| --- a/test/appcatalog.test.mjs |
| +++ b/test/appcatalog.test.mjs |
| @@ -61,7 +61,7 @@ for (const a of report) { |
| assert.strictEqual(typeof a.openEvent, "string"); |
| assert.ok(Array.isArray(a.places)); |
| for (const p of a.places) assert.ok(["you", "chat", "space"].includes(p), `place ${p} unknown`); |
| - for (const k of ["listed", "quiet", "member", "companion", "chatOnly", "chatReadOnly"]) { |
| + for (const k of ["listed", "quiet", "member", "surface", "companion", "chatOnly", "chatReadOnly"]) { |
| assert.strictEqual(typeof a[k], "boolean", `${a.name}.${k} is a boolean`); |
| } |
| assert.ok(["chat", "split", "interface"].includes(a.defaultView), `${a.name}.defaultView normalized`); |
diff --git a/web/static/apps.js b/web/static/apps.js| index fd9db94..08a0683 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -562,6 +562,9 @@ export function appCatalogReport() { |
| // "installs" once you've linked DNSimple — the pickers hide gated apps |
| // until /api/connected lists the service (newchat.js matchingApps). |
| requiresService: app.requiresService || "", |
| + // Whether the app HAS an openable surface (a render) — command-only apps |
| + // (gif) get no sidebar/space-band row anywhere, native included. |
| + surface: typeof app.render === "function", |
| // The presentation contract, pre-answered (same rules as appWantsChatView |
| // / appChatOnly / appDefaultView) so native never re-derives it: companion |
| // = has a Chat view; chatOnly = ONLY a Chat view; otherwise no companion |
diff --git a/web/static/shell.js b/web/static/shell.js| index 6b9eb45..59fff4d 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1067,7 +1067,13 @@ const spaceAppUnread = {}; // appName → unseen activity (local, like the rows |
| // band see the installs. Safe to call repeatedly — re-seeding + catch-up |
| // converge. |
| async function loadSpaceThread() { |
| - if (embed || !instanceKey) return; |
| + // Embeds included, when the native shell seeded the instance key (the |
| + // phase-4 lift, docs/native-apps.md): a command host that knows the space |
| + // thread routes space-installed apps' commands correctly — /gif works |
| + // natively where installed. Without the key (not granted, or an old |
| + // native shell), the bail stands and space commands answer "isn't |
| + // available here yet", exactly as before. |
| + if (!instanceKey) return; |
| try { |
| const r = await (await fetch(`/api/space-thread?token=${encodeURIComponent(session.token)}`)).json(); |
| if (!r.id || !r.open_key) return; // no space installs yet |
| @@ -1420,14 +1426,31 @@ async function bootEmbed() { |
| // purely that app's interface). Assigned directly — the groupapps fold |
| // hasn't run yet, so setAppThreadView's guard would reject it here; the |
| // open below folds state and refreshGroupAppsUI applies the view. |
| - if (embed.app) { |
| + if (embed.app && !embed.space) { |
| groupAppView[tid] = embed.app; |
| appThreadView[tid] = "interface"; |
| } |
| renderApp(); |
| - await openThread(tid); |
| + if (embed.space && embed.app) { |
| + // 🤖 A SPACE APP's surface (docs/native-apps.md phase 4): this page is |
| + // that app rendered full-pane over the space thread — the web's own |
| + // openSpaceApp path, never a chat (the space thread has no chat |
| + // surface; without the flag the dm-fallback row above would mount the |
| + // conversation feed). The seed's thread IS the space thread, so its |
| + // key is already in place; fold its state, then open the app. |
| + spaceThreadId = tid; |
| + await seedAppState(tid, { full: true }); |
| + openSpaceApp(embed.app); |
| + } else { |
| + await openThread(tid); |
| + } |
| connectWS(); |
| installNativeBridge(); |
| + // Space-installed apps' commands route in this host too (phase 4): learn |
| + // the space thread when the native shell seeded the instance key. |
| + // Deliberately not awaited — {ready} must not wait on it; a command |
| + // racing the load still answers honestly ("isn't available here yet"). |
| + if (!embed.space) loadSpaceThread(); |
| // Cross-thread command routing (docs/native-apps.md phase 3): resolving |
| // an app's personal thread needs decrypted name_blobs this page can't |
| // open — route the resolution through the native shell instead of the |