eleven / ocho Public

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>
Paul Campbell 5c52916bea0e5ea464c011827a80a4ab32fe5461 parent 4c625c8
4 files changed, +86 −3
  • ios/LChat/FeedWebCache.swift +14 −0
  • ios/LChat/Views/ChatView.swift +50 −3
  • web/static/shell.js +9 −0
  • web/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));
+}