eleven / messenger Public

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>
James Adam 499640f0e82c2c321c65f42d980f2d3099eca79e parent f03c021
13 files changed, +347 −32
  • docs/native-apps.md +8 −1
  • ios/LChat/FeedWebCache.swift +18 −9
  • ios/LChat/LChatApp.swift +7 −0
  • ios/LChat/Views/SpaceAppScreen.swift +92 −0
  • ios/LChat/Views/ThreadListView.swift +10 −0
  • ios/LChatCore/Sources/LChatCore/AppCatalog.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json +23 −0
  • ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift +31 −4
  • ios/LChatMac/AppThreadWebView.swift +28 −13
  • ios/LChatMac/NativeChatView.swift +95 −1
  • test/appcatalog.test.mjs +1 −1
  • web/static/apps.js +3 −0
  • web/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