import LChatCore import SwiftUI import WebKit // 🤖 An app thread without a native interface (Admin, webhook-monitor, member // apps): the CHAT view is the native feed — NativeChatScreen, the same surface // every chat uses — and only the app's own INTERFACE stays a webview, pinned // to view:"interface". Chat never renders in a webview (the product rule: // webviews are for custom app UI the native primitives can't express, nothing // else); this replaced ChatView, the old whole-thread webview fallback. // // The Chat ↔ Interface switch lives in the ⋮ menu as picker rows — never a // segmented control in the bar (it collided with the floating avatar header, // and Paul's call 2026-07-25: dropdown items, not tabs). In chat mode the // rows ride NativeChatScreen's own ⋮; in interface mode this screen's. // // The registry's presentation decides the shape, mirroring the Mac's ladder // (NativeChatView.companionSplit / appInterfacePane): // companion → Chat + Interface, switched from the ⋮ (iOS has no // split, so defaultView "split" opens chat-first, // exactly as ChatView read it) // companion, chatOnly → just the native chat, no switch // takeover → just the interface webview, no chat at all // schema (fallback) → SchemaAppScreen couldn't build its store; same // companion shape, with the webview as the interface struct AppThreadScreen: View { let instance: SavedInstance let thread: ChatThread let store: ThreadStore @State private var mode: String // "chat" | "interface" private let hasChat: Bool private let hasInterface: Bool // The app posts into its chat, the member doesn't (Admin's update stream — // the manifest's chatReadOnly): the native feed drops its composer. private let chatReadOnly: Bool // Passed through to the chat view: its profile cards hop threads. @Binding private var path: [Route] @Environment(\.dismiss) private var dismiss @MainActor init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, path: Binding<[Route]>) { self.instance = instance self.thread = thread self.store = store self._path = path switch store.registry.presentation(thread.appId ?? "") { case .companion(let defaultView, let chatOnly, let readOnly): hasChat = true hasInterface = !chatOnly chatReadOnly = readOnly _mode = State(initialValue: (defaultView == "interface" && !chatOnly) ? "interface" : "chat") case .schema(let companion): hasChat = companion hasInterface = true chatReadOnly = false _mode = State(initialValue: "interface") default: // .takeover; .flow routes to ElevenChatScreen before here hasChat = false hasInterface = true chatReadOnly = false _mode = State(initialValue: "interface") } } var body: some View { if mode == "chat", hasChat { NativeChatScreen(instance: instance, thread: thread, store: store, path: $path, readOnlyChat: chatReadOnly, appView: hasInterface ? $mode : nil) } else { AppInterfacePane(instance: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) .navigationTitle(thread.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { AppInterfaceKebab(store: store, thread: thread, appView: hasChat ? $mode : nil, onClose: { dismiss() }) } } } } } // The Chat ↔ Interface switch as dropdown picker rows: Chat plus the app // itself (its own title + symbol), checkmarked by selection. Rides inside a // ⋮ Menu — NativeChatScreen's in chat mode, AppInterfaceKebab's in interface // mode — so the nav bar stays clear for the floating header. struct AppViewPicker: View { let store: ThreadStore let thread: ChatThread @Binding var view: String // "chat" | "interface" var body: some View { Picker("View", selection: $view) { Label("Chat", systemImage: "bubble.left").tag("chat") // Labels with SF-symbol icons only — iOS menus silently drop // non-image icons (the emoji glyphs never rendered). Label(store.registry.title(thread.appId ?? ""), systemImage: AppIdentity.symbol(for: thread.appId ?? "") ?? "square.grid.2x2") .tag("interface") } .pickerStyle(.inline) } } // The interface view's ⋮: the view switch (when the app has a Chat view at // all) plus the chat-management basics the chat chrome would otherwise carry. struct AppInterfaceKebab: View { let store: ThreadStore let thread: ChatThread /// nil = no Chat view to switch to (takeover apps). let appView: Binding? let onClose: () -> Void var body: some View { Menu { if let appView { AppViewPicker(store: store, thread: thread, view: appView) } Button { store.setPinned(thread, !isPinned) } label: { Label(isPinned ? "Unpin chat" : "Pin chat", systemImage: isPinned ? "pin.slash" : "pin") } Button(role: .destructive) { store.setHidden(thread, true) onClose() } label: { Label("Close chat", systemImage: "xmark.circle") } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel("Chat options") } // Live pinned state: the store row changes as the menu toggles it. private var isPinned: Bool { store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned } } // The app's own face: the embed page (FeedWebCache) seeded — and held — on // view:"interface". The page can carry the whole web client, but this surface // only ever shows the app pane; the re-asserts pin a parked or freshly booted // page there so it can never sit on a chat view (GroupAppPane's pattern). struct AppInterfacePane: View { @ObservedObject private var bridge: WebBridge let instance: SavedInstance let thread: ChatThread let myUserId: String let instanceKeyB64: String? @MainActor init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) { self.instance = instance self.thread = thread self.myUserId = myUserId self.instanceKeyB64 = instanceKeyB64 bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) } var body: some View { VStack(spacing: 0) { AppUpdateBar(bridge: bridge) ZStack { AppInterfaceWebView(instance: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) if !bridge.pageReady { WebLoadingVeil().transition(.opacity) } } } .animation(.easeOut(duration: 0.25), value: bridge.pageReady) .onAppear { bridge.setAppView("interface") } .onChange(of: bridge.pageReady) { _, ready in if ready { bridge.setAppView("interface") } } } } private struct AppInterfaceWebView: UIViewRepresentable { let instance: SavedInstance let thread: ChatThread let myUserId: String let instanceKeyB64: String? func makeUIView(context: Context) -> WKWebView { FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) } func updateUIView(_ web: WKWebView, context: Context) {} }