Eleven conversation: own bubbles align trailing; Open admin hops natively (#543)
* web+ios+mac: the joiner welcome conversation runs natively — land in a chat with Eleven The embed boot (bootEmbed) now arms the same client-local welcome flow the full web boot runs, when the embedded thread is the welcome app-thread: Eleven greets, asks the name, offers the photo — no bot, no AI. env.native skips the passkey step (access is already secured by Sign in with Eleven) and the access step records that reason instead. The flow's finish step now persists a completion marker (new step-level record support in flow.js) — the native embed has no localStorage join marker, so 'is this conversation over?' is read from recorded state, and a finished conversation never re-arms. Native lands members there: iOS opens the welcome thread on first entry into a space, the Mac selects it instead of the first chat — once per device+instance (WelcomeLanding, gated on the conversation being unfinished, read from the encrypted apps snapshot with the thread key). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web+ios+mac: creators land in Eleven's admin setup conversation after native create-a-space The creator-side sibling of the joiner welcome landing: the native claim pipeline creates the 'setup' app-thread and flags the instance; iOS/Mac land there on first entry, and the thread's webview runs the same client-local setup conversation the web runs — with env.native skipping the passkey pitch (the space's key is already saved to the creator's Eleven account) and hiding the cross-thread Open-admin chip. Arming is explicit (embed.armSetup from the pending flag), never a plain thread tap, so old admins' long-finished setup threads — which predate the finish step recording a completion marker — can never re-run. setup.flow.js: the greeting moves to its own hello step and resumedIf now means 'a step was recorded' (a pre-set name alone is the native creator's FIRST run and still gets greeted); the keys step gains the native bypass; finish records its completion marker (like welcome). The eager setup_complete at claim stays — it's the reaper's not-abandoned signal, now decoupled from the conversation's own completion state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge origin/main (Mac spaces menu, eleven CLI bundle, cards) * ios+mac: Eleven's onboarding conversations run FULLY native — a Swift flow interpreter, no webview FlowEngine (LChatCore) is the Swift twin of flow.js — the interpreter the conversation-scripts doc promised natives. It walks the same flow documents (Flows/*.json, byte-derived from *.flow.js, drift-gated in npm test) with the identical semantics: entries, skips, templates, chips, asks, effect branching, settle polls, records. FlowEngineTests mirror test/flow.test.mjs over the real bundled flows. ElevenConversationModel binds it to a thread: folds the persisted transcript (say/step events + the member's answers — wire-identical to the web, so a conversation resumes across surfaces), runs native verbs (admin POSTs, label publish; passkey skipped by env.native), and persists each line as it speaks. ElevenChatView is the shared SwiftUI surface: Eleven's bubbles, tappable chips, action lines, a typing beat, a plain composer. Because the flows ship in the app bundle, onboarding needs NO server web-code deploy — it works against any instance. The webview embed arms (maybeStartEmbedWelcome/Setup, armSetup seeds) are removed as dead; native v1 skips the photo steps (Admin/profile cover them) and the web-only tour chip, gated in the flow data under 'native'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: fix conversation echo-duplicates (dedupe folds on said/step/pending state); Eleven wears the app icon Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: no avatars beside conversation bubbles (product convention in every chat stream) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: the Eleven conversation honours the app's text size (\.chatFontSize) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: the Eleven conversation reuses the canonical chat components Bubbles render through MessageBubble (theme, reading size, selection — identical to every native feed), the typing beat through TypingBubble, action lines via the feeds' exact recipe, and the composer is the Mac feed composer's pill (bordered pill on the panel background, same send disc), themed from the same nativeTheme pref on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: composer stays typeable when the hint changes (recreate the field, hand focus back; stop the chip ticker destroying view identity) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: a question's composer hint doesn't outlive its step Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: Eleven's threads wear the brand icon, not the clock glyph ElevenBrand.icon (the bundled icon-192, web parity with welcome.js's avatar) wins over the app glyph in the sidebar, header, search hits and rail for setup/welcome threads. Eleven has no gender and no face — the mark is the product's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge cleanup: keep main's threadState/decryptData, one encryptData Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: own bubbles align trailing in the Eleven conversation (the feeds' rule) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * setup finish: Open admin hops natively (create + open the Admin app thread) The chip was gated off native when the conversation lived in a single-thread webview; the native view hops threads, so the gate moves to just the web-only tour. Reopening a finished conversation re-offers the chips (say-dedupe attaches them to the existing line). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+28
−8
ios/LChat/Views/ElevenChatScreen.swift+7 −0ios/LChatCore/Sources/LChatCore/ElevenChatView.swift+2 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+6 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+0 −1ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift+2 −2ios/LChatMac/ElevenConversationPane.swift+7 −0test/flow.test.mjs+2 −2web/static/apps/setup.flow.js+2 −3
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift| index b1cc3cc..9a8ee6f 100644 |
| --- a/ios/LChat/Views/ElevenChatScreen.swift |
| +++ b/ios/LChat/Views/ElevenChatScreen.swift |
| @@ -30,6 +30,13 @@ struct ElevenChatScreen: View { |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| myUserId: store.myUserId) |
| m?.openThread = { id in path.append(id) } |
| + m?.openAdmin = { |
| + Task { @MainActor in |
| + if let id = await store.openOrCreateAppThread(app: "admin", title: "Admin") { |
| + path.append(id) |
| + } |
| + } |
| + } |
| m?.start(roster: store.roster, threads: store.threads) |
| model = m |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift| index 20382fe..061cd21 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| @@ -65,10 +65,12 @@ public struct ElevenChatView: View { |
| .padding(.leading, 4) |
| } |
| } |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| .padding(.vertical, 2) |
| case .mine(let text): |
| MessageBubble(message: bubbleMessage(row, text: text, mine: true), |
| showSender: false, theme: theme) |
| + .frame(maxWidth: .infinity, alignment: .trailing) |
| .padding(.vertical, 2) |
| case .action(let text): |
| // The feeds' exact /me action-line recipe (NativeFeedView.row). |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 94c6f80..52b6645 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -52,6 +52,9 @@ public final class ElevenConversationModel: ObservableObject { |
| private var answerHandler: ((FlowAnswer) -> Void)? |
| /// Native navigation for the finish chips ("Say hi to {inviter}"). |
| public var openThread: ((String) -> Void)? |
| + /// The finish chip's Admin hop: open (creating if needed) the Admin app |
| + /// thread — the host owns thread creation and navigation. |
| + public var openAdmin: (() -> Void)? |
| public init?(instance: SavedInstance, thread: ChatThread, myUserId: String) { |
| guard let appId = thread.appId, appId == "setup" || appId == "welcome", |
| @@ -319,6 +322,9 @@ extension ElevenConversationModel: FlowIO { |
| case "openPendingThread": |
| if let hop = pendingHop { openThread?(hop) } |
| return .null |
| + case "openAdmin": |
| + openAdmin?() |
| + return .null |
| default: |
| return .null // unknown/web-only verbs are quiet no-ops natively |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 048c1d4..a8bb17d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -503,7 +503,6 @@ |
| ], |
| "chips": [ |
| { |
| - "if": "!native", |
| "label": "Open admin", |
| "advancing": false, |
| "effect": { |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift| index e10d71c..c8cecd7 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| @@ -145,10 +145,10 @@ final class FlowEngineTests: XCTestCase { |
| XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }) |
| XCTAssertFalse(io.verbLog.contains { $0.0 == "deferBackupReminders" }) |
| XCTAssertFalse(io.transcript.contains { $0.text.contains("come back to that key backup") }) |
| - // No web-only chips natively (admin hop, tour). |
| + // The tour stays web-only; Admin hops natively now. |
| let allChips = io.transcript.flatMap { $0.chips ?? [] }.map(\.label) |
| XCTAssertFalse(allChips.contains("Take the tour")) |
| - XCTAssertFalse(allChips.contains("Open admin")) |
| + XCTAssertTrue(allChips.contains("Open admin")) |
| } |
| // MARK: the native joiner welcome run |
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift| index fb75850..8a123ca 100644 |
| --- a/ios/LChatMac/ElevenConversationPane.swift |
| +++ b/ios/LChatMac/ElevenConversationPane.swift |
| @@ -27,6 +27,13 @@ struct ElevenConversationPane: View { |
| let m = ElevenConversationCache.shared.model(for: instance, thread: thread, |
| myUserId: store.myUserId) |
| m?.openThread = { id in selectedID = id } |
| + m?.openAdmin = { |
| + Task { @MainActor in |
| + if let id = await store.openOrCreateAppThread(app: "admin", title: "Admin") { |
| + selectedID = id |
| + } |
| + } |
| + } |
| m?.start(roster: store.roster, threads: store.threads) |
| model = m |
| } |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 2b46990..0ae2416 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -300,8 +300,8 @@ await test("setup: native creator — greeting shows, name pre-set, passkey neve |
| assert.equal(h.verbLog.some((v) => v[0] === "deferBackupReminders"), false); |
| assert.equal(h.transcript.some((t) => /come back to that key backup/.test(t.text)), false); |
| const chipLabels = h.transcript.flatMap((t) => t.chips || []); // harness stores labels |
| - assert.equal(chipLabels.includes("Take the tour"), false); |
| - assert.equal(chipLabels.includes("Open admin"), false); |
| + assert.equal(chipLabels.includes("Take the tour"), false, "the tour stays web-only"); |
| + assert.ok(chipLabels.includes("Open admin"), "native hops to Admin"); |
| }); |
| await test("engine: template + condition mini-language", () => { |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index bd96b47..64aab5e 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -331,9 +331,8 @@ export default { |
| { text: "Where would you like to start?" }, |
| ], |
| chips: [ |
| - // The embed renders one thread — no cross-thread hop to the Admin |
| - // app there; the bubble's /admin pointer covers it. |
| - { if: "!native", label: "Open admin", advancing: false, effect: { verb: "openAdmin" } }, |
| + // Native hops threads too (the conversation model's openAdmin verb). |
| + { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } }, |
| // The tour is the web's guided overlay — web-only for now. |
| { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } }, |
| ], |