iOS: header toggle reaches the app view (Chat ↔ Interface) (#422)
* ios,web: header toggle reaches the app view on iOS On iOS a companion app-thread (To-do, Reminders) only ever showed its Chat view — the web header's view toggle isn't rendered in embed, so Interface was unreachable. Now ChatView puts a toggle beside the ⋯ menu: a grid icon switches to the app view, a bubble icon switches back. The native composer steps aside in Interface (the app surface owns its own inputs). Wiring: shell.js gains window.lchatNative.setAppView (drives the existing setAppThreadView), the embed seed carries the initial view, and AppViewMemory (shared by seed + toggle) keeps the button and the page agreeing across parked reopens AND fresh boots — with an onAppear re-assert as the belt and braces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: view switch moves into the ⋯ menu — 'App view' / 'Chat view' Paul's call: no standalone toolbar button; the switch is the menu's first item, named for where it takes you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: embed Interface pane clears the native header + home bar The app-pane scroller now pads its top with --native-top-inset (the same floating-bar clearance the feed gets) and ends with an in-flow spacer for the bottom inset — To-do's add row no longer hides under the iOS header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+86
−3
ios/LChat/FeedWebCache.swift+14 −0ios/LChat/Views/ChatView.swift+50 −3web/static/shell.js+9 −0web/static/style.css+13 −0
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index 87197af..4cd848d 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -121,6 +121,7 @@ final class FeedWebCache { |
| // Runs before any page script: hand chat.js the embed seed for this thread. Only |
| // the thread key crosses over — never the user private key. |
| + // (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?) -> WKUserScript? { |
| var seed: [String: Any] = [ |
| @@ -129,6 +130,11 @@ final class FeedWebCache { |
| "myUserId": myUserId, |
| "threadId": thread.id, |
| "threadKey": thread.threadKeyB64, |
| + // Companion app-threads open on the remembered view (default chat), |
| + // deterministically — the native header's toggle (ChatView) drives |
| + // any switch and shares this memory, so the button state and the |
| + // page always start in agreement, parked OR freshly booted. |
| + "view": AppViewMemory.views[thread.id] ?? "chat", |
| ] |
| if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| @@ -162,3 +168,11 @@ final class FeedWebCache { |
| e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat") |
| } |
| } |
| + |
| +// The per-thread companion-app view choice ("chat" | "interface") for this |
| +// session — ONE truth shared by the boot seed above (a fresh page opens on it) |
| +// and ChatView's header toggle (its button state + live switches). Session |
| +// memory only, like the web's appThreadView; deliberately not persisted. |
| +enum AppViewMemory { |
| + static var views: [String: String] = [:] |
| +} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index cbf9805..17eb845 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -41,6 +41,14 @@ struct ChatView: View { |
| @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? |
| @Environment(\.scenePhase) private var scenePhase |
| + // Which view a companion app-thread shows: the Chat conversation or the |
| + // app's Interface, toggled from the header button. Remembered per thread |
| + // for the session in AppViewMemory (shared with the webview boot seed) |
| + // because a parked page keeps ITS state across reopens and a fresh page |
| + // boots from the seed — the button and the page must always agree, and |
| + // onAppear re-asserts to be sure. |
| + @State private var appView: String |
| + |
| let instance: SavedInstance |
| let thread: ChatThread |
| var myUserId: String = "" |
| @@ -56,10 +64,25 @@ struct ChatView: View { |
| self.instanceKeyB64 = instanceKeyB64 |
| bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + _appView = State(initialValue: AppViewMemory.views[thread.id] ?? "chat") |
| } |
| private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| + // Companion app-threads (a Chat view AND an Interface view — To-do, |
| + // Reminders) get the header toggle; takeover apps are their interface |
| + // already, and plain chats have nothing to toggle. The intros table is |
| + // LChatCore's existing "which apps are companion" fact. |
| + private var isCompanionApp: Bool { |
| + thread.appId.flatMap { AppChatProjection.intros[$0] } != nil |
| + } |
| + |
| + private func setAppView(_ view: String) { |
| + appView = view |
| + AppViewMemory.views[thread.id] = view |
| + bridge.setAppView(view) |
| + } |
| + |
| // Live pinned state: the store row changes as the menu toggles it, so read from |
| // there (falling back to the value captured when this view was built). |
| private var isPinned: Bool { |
| @@ -138,7 +161,10 @@ struct ChatView: View { |
| } |
| } |
| .safeAreaInset(edge: .bottom, spacing: 0) { |
| - if instance.baseURL != nil { composer } |
| + // In the Interface view the app surface owns the whole pane — |
| + // including its own inputs (To-do's add row, Reminders' form) — |
| + // so the native chat composer steps out of the way. |
| + if instance.baseURL != nil, appView == "chat" { composer } |
| } |
| .background(EdgeOnlyBackGesture()) |
| .navigationTitle("") |
| @@ -156,10 +182,19 @@ struct ChatView: View { |
| if !isMac { chatHeader } |
| } |
| .toolbar { |
| - // Top-right overflow: pin/unpin and close this chat (both per-user, |
| - // synced by the server). Closing pops back to the list. |
| + // Top-right overflow: the companion-app view switch (named for |
| + // where it TAKES you — "App view" from chat, "Chat view" from the |
| + // app), then pin/unpin and close (both per-user, server-synced). |
| ToolbarItem(placement: .primaryAction) { |
| Menu { |
| + if isCompanionApp { |
| + Button { |
| + setAppView(appView == "chat" ? "interface" : "chat") |
| + } label: { |
| + Label(appView == "chat" ? "App view" : "Chat view", |
| + systemImage: appView == "chat" ? "square.grid.2x2" : "bubble.left") |
| + } |
| + } |
| Button { |
| store.setPinned(thread, !isPinned) |
| } label: { |
| @@ -180,6 +215,9 @@ struct ChatView: View { |
| } |
| .onAppear { |
| bridge.didAttach(force: consumeNotificationScroll()) // a cached feed may need re-pinning to its newest message |
| + // Re-assert the remembered view on the (possibly parked) page so |
| + // the toggle button and the webview can never disagree. |
| + if isCompanionApp { bridge.setAppView(appView) } |
| if isMac { composerFocused = true } |
| // A little finesse once the pane has landed: the avatar springs up past |
| // full size and settles back — a low damping fraction gives the overshoot. |
| @@ -641,6 +679,15 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| "window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach(\(force))") |
| } |
| + // Switch a companion app-thread's webview between its Chat and Interface |
| + // views (shell.js setAppThreadView, via the embed bridge) — the native |
| + // header toggle drives this. `view` is "chat" or "interface" (ours, never |
| + // user text — safe to interpolate). |
| + func setAppView(_ view: String) { |
| + webView?.evaluateJavaScript( |
| + "window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')") |
| + } |
| + |
| // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement |
| // these — chat.js's "Delete this message" confirm (reachable here since embed mode |
| // still shares renderApp()/openThread() with the full app) silently no-ops without |
diff --git a/web/static/shell.js b/web/static/shell.js| index 170cab5..49ab94b 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1006,6 +1006,15 @@ function installNativeBridge() { |
| if (chatAppNative) return chatAppNative.cancelEdit(); |
| cancelEdit(); |
| }, |
| + // Switch a companion app-thread between its Chat and Interface views — |
| + // the native header's toggle drives this (the web's own header toggle |
| + // isn't rendered in embed, so without it the second view is unreachable |
| + // on iOS). No-op for non-app threads (setAppThreadView guards). |
| + setAppView(view) { |
| + const tid = embed && embed.threadId; |
| + if (!tid) return; |
| + setAppThreadView(tid, view === "interface" ? "interface" : "chat"); |
| + }, |
| // Composer typeahead for the native input: same token detection + ranking |
| // as the web menu (composerToken + typeaheadItems). caret is a UTF-16 |
| // offset, matching JS string indexing. Returns {start, items} or null. |
diff --git a/web/static/style.css b/web/static/style.css| index 514aac8..3d73148 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -2006,3 +2006,16 @@ html.native .thread-head { |
| the native composer (drag-drop or the native + button stage into it). The composer |
| below is hidden, so clear the native bar's height (plus the old 10px breathing room). */ |
| .native-embed .composer-preview { padding-bottom: calc(10px + var(--native-bottom-inset, 0px)); } |
| +/* The Interface view (an .app-pane scroller) runs under the same floating |
| + native bars as the feed: pad its top with the injected inset so the app's |
| + own UI (To-do's add row, Reminders' form) starts clear of the header, and |
| + end with a real in-flow spacer — not padding-bottom, which WebKit drops at |
| + a scroller's end — so the last row clears the home indicator/keyboard. */ |
| +.native-embed #interface-pane { |
| + padding-top: calc(16px + var(--native-top-inset, 0px)); |
| +} |
| +.native-embed #interface-pane::after { |
| + content: ""; |
| + flex: none; |
| + height: calc(16px + var(--native-bottom-inset, 0px)); |
| +} |