eleven / messenger Public

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>
Paul Campbell 4f8e1cf4cdbde9470f356b4e154c61c1c56a79e8 parent 6bff8c5
8 files changed, +28 −8
  • ios/LChat/Views/ElevenChatScreen.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/ElevenChatView.swift +2 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +6 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +0 −1
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +2 −2
  • ios/LChatMac/ElevenConversationPane.swift +7 −0
  • test/flow.test.mjs +2 −2
  • web/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" } },
],