eleven / messenger Public

Quick forms: fill in onboarding at once (web + native) (#719)

* web: quick forms — fill in onboarding at once, ending in one Sign in

Both onboarding conversations (joiner welcome, admin setup) gain a fast
path: their first interactive step offers a right-aligned trailing chip
("Fill in a form instead") that opens a modal form with every question
at once. Submitting FAST-FILLS the flow's own records — the same bridge
verbs and the same step records the conversational path would produce
(planQuickForm, labels verbatim from the flow documents) — then hands
over to the same sign-in ceremony; the flow's resume machinery does the
rest (the access/keys step's settle + the enrolment completion poller).

- flow.js: chips pass `trailing` through; apps.js right-aligns the first
  trailing chip (margin-inline-start, RTL-safe)
- quickform.js: the pure values→verbs+records planner, unit-tested
- shell.js: the two form sheets (modal + .eleven-input fields), record
  emission with fold-confirmation before the sign-in navigation, and the
  stay-context reload fallback
- native flow twins (Flows/*.json) regenerated; drift gate green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the quick form natively — trailing chips, a shared sheet, fast-fill + re-entry

The native halves of the quick-form fast path (iOS first, Mac in the
same change):

- FlowEngine: FlowChip carries `trailing`; FlowChipWrap renders trailing
  chips in their own right-aligned row below the others (steps without
  trailing chips keep the exact pre-existing layout)
- ElevenConversationModel: the openQuickForm verb/callback, and
  applyQuickForm — the same verbs + recordIfNew step records the
  conversation would produce (native flows skip the sign-in and photo
  steps, so welcome = name only; setup = name, space, email, invites,
  extras). Advancing without taps = a clean engine re-entry (restartFlow
  → startEngine), the exact path a fresh open takes: the new run folds
  the fast-filled records and walks itself to the finish
- ElevenQuickFormView (LChatCore, shared): the Form sheet both hosts
  present; primary button is Save (no sign-in step natively)
- FlowEngineTests: the trailing-chip mirror of the JS test; also fixes
  the stale native setup run (#716 added the extras step to the flow but
  never gave this test its "Keep everything on" tap)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 08a8777d93536e0027b15fb3cec4668323f6e7f2 parent 65e4d14
22 files changed, +932 −29
  • changelog/2026-07-11-quick-form.md +5 −0
  • ios/LChat/Views/ElevenChatScreen.swift +9 −0
  • ios/LChatCore/Sources/LChatCore/ElevenChatView.swift +45 −21
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +118 −4
  • ios/LChatCore/Sources/LChatCore/ElevenQuickFormView.swift +154 −0
  • ios/LChatCore/Sources/LChatCore/FlowEngine.swift +6 −1
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +10 −0
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +8 −0
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +46 −0
  • ios/LChatMac/ElevenConversationPane.swift +9 −0
  • package.json +1 −1
  • test/flow.test.mjs +32 −1
  • test/quickform.test.mjs +92 −0
  • web/static/apps.js +11 −1
  • web/static/apps/flow.js +3 −0
  • web/static/apps/quickform.js +76 −0
  • web/static/apps/setup.flow.js +11 −0
  • web/static/apps/setup.js +4 −0
  • web/static/apps/welcome.flow.js +10 −0
  • web/static/apps/welcome.js +4 −0
  • web/static/shell.js +268 −0
  • web/static/style.css +10 −0
diff --git a/changelog/2026-07-11-quick-form.md b/changelog/2026-07-11-quick-form.md
new file mode 100644
index 0000000..d222936
--- /dev/null
+++ b/changelog/2026-07-11-quick-form.md
@@ -0,0 +1,5 @@
+🤖 In a hurry? Fill in a form instead. The welcome and setup conversations now
+offer a quick form with everything at once — your name (plus, for a new space,
+its name, contact email, who can invite, and the extras) — ending in one Sign
+in. Eleven's conversation picks up right where the form leaves off, and taking
+it question by question works exactly as before.
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift
index 5ed3935..7f5c419 100644
--- a/ios/LChat/Views/ElevenChatScreen.swift
+++ b/ios/LChat/Views/ElevenChatScreen.swift
@@ -12,6 +12,7 @@ struct ElevenChatScreen: View {
@Binding var path: [Route]
@State private var model: ElevenConversationModel?
@State private var showDiscover = false
+ @State private var showQuickForm = false
@ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
@AppStorage("nativeTheme") private var themeName = "classic"
@@ -36,10 +37,18 @@ struct ElevenChatScreen: View {
}
}
}
+ .sheet(isPresented: $showQuickForm) {
+ // "Fill in a form instead" — the whole conversation's questions at
+ // once; submitting fast-fills the flow's records and it resumes.
+ if let model {
+ ElevenQuickFormView(model: model)
+ }
+ }
.onAppear {
guard model == nil else { return }
let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
myUserId: store.myUserId)
+ m?.openQuickForm = { showQuickForm = true }
m?.openThread = { id in path.append(.thread(id)) }
m?.openAdmin = {
Task { @MainActor in
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift
index 64deb39..8eb5e0d 100644
--- a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift
+++ b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift
@@ -182,6 +182,10 @@ public struct ElevenChatView: View {
}
// A wrapping row of chips; each greys to its spent label once tapped or stale.
+// Trailing chips (the quiet alternative path, e.g. the quick form) render in
+// their own right-aligned row BELOW the others — the simplest correct twin of
+// the web's end-of-row alignment; a step with no trailing chips renders through
+// the exact pre-existing single-layout path, pixel-identical.
struct FlowChipWrap: View {
let chips: [FlowChip]
var accent: Color = .accentColor
@@ -190,28 +194,25 @@ struct FlowChipWrap: View {
@State private var beat = 0
var body: some View {
- FlowLayoutHStack {
- ForEach(chips) { chip in
- // `beat < 0` is always false — it exists to make body READ the
- // beat so staleness re-evaluates, WITHOUT the identity-destroying
- // .id(beat) (which re-created subtrees every 500ms and fought
- // the composer's field editor for first responder).
- let spent = tapped.contains(chip.id) || chip.isStale() || beat < 0
- Button {
- guard !spent else { return }
- tapped.insert(chip.id)
- chip.run()
- } label: {
- Text(spent ? (chip.spentLabel ?? chip.label) : chip.label)
- .font(.system(size: max(12, fontSize - 2)))
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(Capsule().fill(spent ? AnyShapeStyle(.quaternary.opacity(0.4))
- : AnyShapeStyle(accent.opacity(0.14))))
- .foregroundStyle(spent ? Color.secondary : accent)
+ let leading = chips.filter { !$0.trailing }
+ let trailing = chips.filter { $0.trailing }
+ Group {
+ if trailing.isEmpty {
+ FlowLayoutHStack {
+ ForEach(chips) { chipButton($0) }
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 6) {
+ if !leading.isEmpty {
+ FlowLayoutHStack {
+ ForEach(leading) { chipButton($0) }
+ }
+ }
+ HStack(spacing: 6) {
+ Spacer(minLength: 0)
+ ForEach(trailing) { chipButton($0) }
+ }
}
- .buttonStyle(.plain)
- .disabled(spent)
}
}
// Staleness is a closure over engine state — re-read it on a slow beat.
@@ -222,6 +223,29 @@ struct FlowChipWrap: View {
}
}
}
+
+ @ViewBuilder private func chipButton(_ chip: FlowChip) -> some View {
+ // `beat < 0` is always false — it exists to make body READ the
+ // beat so staleness re-evaluates, WITHOUT the identity-destroying
+ // .id(beat) (which re-created subtrees every 500ms and fought
+ // the composer's field editor for first responder).
+ let spent = tapped.contains(chip.id) || chip.isStale() || beat < 0
+ Button {
+ guard !spent else { return }
+ tapped.insert(chip.id)
+ chip.run()
+ } label: {
+ Text(spent ? (chip.spentLabel ?? chip.label) : chip.label)
+ .font(.system(size: max(12, fontSize - 2)))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Capsule().fill(spent ? AnyShapeStyle(.quaternary.opacity(0.4))
+ : AnyShapeStyle(accent.opacity(0.14))))
+ .foregroundStyle(spent ? Color.secondary : accent)
+ }
+ .buttonStyle(.plain)
+ .disabled(spent)
+ }
}
// A minimal wrapping layout (chips can outgrow one line).
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
index 2a02aac..761d688 100644
--- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
@@ -61,6 +61,9 @@ public final class ElevenConversationModel: ObservableObject {
/// The welcome finish's "Browse open groups" chip — the host presents the
/// Discover surface (the twin of the web's showDiscover).
public var openDiscover: (() -> Void)?
+ /// The "Fill in a form instead" chip — the host presents the quick-form
+ /// sheet (ElevenQuickFormView), which submits back through applyQuickForm.
+ public var openQuickForm: (() -> Void)?
/// The welcome flow's name step just published a label — the host must
/// also persist the name into the saved instance (outgoing payloads stamp
/// SavedInstance.screenName; without this a flow rename fixes the roster
@@ -96,13 +99,20 @@ public final class ElevenConversationModel: ObservableObject {
private func load(roster: [RosterEntry], threads: [ChatThread]) async {
guard let api, let key else { return }
+ // Kept for engine re-entries (the quick form's fast-fill restarts the
+ // run the way a fresh open would — see restartFlow).
+ envRoster = roster
+ envThreads = threads
let messages = await api.messages(thread: thread.id, after: 0)
for m in messages { fold(id: m.id, blob: m.blob, key: key) }
startSocket()
ready = true
+ await startEngine()
+ }
- guard let flow = AppFlows.flow(appId) else { return }
- env = FlowEnv()
+ private func startEngine() async {
+ guard let api, let flow = AppFlows.flow(appId) else { return }
+ let env = FlowEnv()
env.flags["native"] = .bool(true)
env.flags["suggested"] = .string("")
// The roster carries no role natively — the greeting falls back to
@@ -111,9 +121,9 @@ public final class ElevenConversationModel: ObservableObject {
// The inviter: the other member of the joiner's first person-DM.
var inviter = ""
var pendingThreadId: String?
- if let dm = threads.first(where: { $0.kind == "dm" && $0.appId == nil }) {
+ if let dm = envThreads.first(where: { $0.kind == "dm" && $0.appId == nil }) {
if let other = dm.members.first(where: { $0 != myUserId }),
- let entry = roster.first(where: { $0.user_id == other }) {
+ let entry = envRoster.first(where: { $0.user_id == other }) {
inviter = entry.label
pendingThreadId = dm.id
}
@@ -137,12 +147,15 @@ public final class ElevenConversationModel: ObservableObject {
env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
let hop = pendingThreadId
pendingHop = hop
+ self.env = env
let engine = FlowEngine(flow: flow, env: env, io: self)
self.engine = engine
engine.start()
}
private var pendingHop: String?
+ private var envRoster: [RosterEntry] = []
+ private var envThreads: [ChatThread] = []
// Folds dedupe on CONVERSATION state (said keys, recorded steps, pending
// own sends), not just message ids — our own optimistic rows race the
@@ -240,6 +253,104 @@ public final class ElevenConversationModel: ObservableObject {
}
return true
}
+
+ // MARK: the quick form ("Fill in a form instead")
+
+ /// Which quick form the sheet draws ("setup" | "welcome") + its prefills.
+ public var quickFormIsSetup: Bool { appId == "setup" }
+ /// The current space name, only when it was explicitly set (the flow's
+ /// "Keep" affordance becomes a prefill).
+ public var quickFormSpaceName: String {
+ (env.space["nameSet"]?.truthy == true) ? (env.space["name"]?.stringValue ?? "") : ""
+ }
+
+ /// Fast-fill: run the SAME verbs and emit the SAME step records the
+ /// conversational path would (labels verbatim from the flow documents),
+ /// then re-enter the flow so it resumes over the recorded steps — no taps
+ /// needed. Native flows skip the access/keys sign-in and the photo step,
+ /// so no records are needed for those here. Returns a user-facing error
+ /// message (the sheet stays open), or nil on success.
+ public func applyQuickForm(_ v: QuickFormValues) async -> String? {
+ // The typed answer's clean-before-cap rule (flow.js / FlowEngine twin).
+ let name = String(cleanNameAnswer(v.name).prefix(32))
+ guard !name.isEmpty else { return "Add your name to continue." }
+ _ = await verb("setMyName", args: [.string(name)])
+ await recordIfNew(step: "name", label: "noted: you're \(name)", value: name)
+ if appId == "setup" {
+ let space = v.spaceName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let r = await verb("setGroupName", args: [.string(space)])
+ if FlowEngine.resultKey(r) == "invalid" {
+ return "That space name didn't stick — it needs to be 1–60 characters."
+ }
+ if FlowEngine.resultKey(r) != "ok" {
+ return "Something went wrong saving the space name — try again in a moment."
+ }
+ await recordIfNew(step: "group", label: "named this space “\(space)”", value: space)
+ let email = v.email.trimmingCharacters(in: .whitespacesAndNewlines)
+ if email.isEmpty {
+ await recordIfNew(step: "email", label: "skipped the contact email", value: "skipped")
+ } else {
+ if FlowEngine.resultKey(await verb("setContactEmail", args: [.string(email)])) != "ok" {
+ return "That doesn't look like an email address — fix it, or leave it empty."
+ }
+ await recordIfNew(step: "email", label: "noted a contact email", value: email)
+ }
+ _ = await verb("setUserInvites", args: [.bool(v.membersInvite)])
+ await recordIfNew(step: "invites",
+ label: v.membersInvite ? "decided members can invite people"
+ : "decided only the admin invites people",
+ value: v.membersInvite ? "members" : "admin only")
+ // The extras trio mirrors the conversation's two paths: everything
+ // on is the one-tap record (defaults are already on server-side),
+ // anything off walks the granular records, writing each gate.
+ if v.ai && v.bots && v.apps {
+ await recordIfNew(step: "extras", label: "kept all the extras on", value: "all-on")
+ } else {
+ for (key, on, noun) in [("ai", v.ai, "AI"), ("bots", v.bots, "bots"), ("apps", v.apps, "apps")] {
+ _ = await verb("setFeature", args: [.string(key), .bool(on)])
+ await recordIfNew(step: "extras-\(key)",
+ label: on ? "enabled \(noun)" : "switched \(noun) off",
+ value: on ? "on" : "off")
+ }
+ }
+ }
+ restartFlow()
+ return nil
+ }
+
+ // The flow never re-records a done step (recordIfNew discipline) — a form
+ // submitted after some conversational answers mustn't duplicate their
+ // action lines; its verbs still ran, so the changed values apply.
+ private func recordIfNew(step: String, label: String, value: String) async {
+ if done(step) { return }
+ await record(step: step, label: label, value: value)
+ }
+
+ /// A clean re-entry: the live engine is parked mid-step (its armed ask and
+ /// settle belong to the step the form just answered), so rather than poke
+ /// its internals, drop it and start a fresh run — the exact path a fresh
+ /// open takes. The new run folds the fast-filled records (the model's
+ /// `steps`/`said` maps carry them) and walks the remaining steps to the
+ /// finish on its own — the "advance without user taps".
+ private func restartFlow() {
+ cancelAnswer()
+ engine = nil
+ Task { @MainActor in await startEngine() }
+ }
+}
+
+/// What the quick-form sheet collects. Native flows skip the access/keys
+/// sign-in and the photo step, so: welcome = name only; setup adds the space
+/// name, contact email, invites, and the extras trio.
+public struct QuickFormValues: Sendable {
+ public var name = ""
+ public var spaceName = ""
+ public var email = ""
+ public var membersInvite = true
+ public var ai = true
+ public var bots = true
+ public var apps = true
+ public init() {}
}
// MARK: - the flow io
@@ -364,6 +475,9 @@ extension ElevenConversationModel: FlowIO {
case "openDiscover":
openDiscover?()
return .null
+ case "openQuickForm":
+ openQuickForm?()
+ return .null
default:
return .null // unknown/web-only verbs are quiet no-ops natively
}
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenQuickFormView.swift b/ios/LChatCore/Sources/LChatCore/ElevenQuickFormView.swift
new file mode 100644
index 0000000..8ade0e9
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ElevenQuickFormView.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+
+// 🤖 The native quick form ("Fill in a form instead"): every onboarding
+// question at once, submitted as a fast-fill through
+// ElevenConversationModel.applyQuickForm — the same verbs and step records the
+// conversation would produce, after which the flow re-enters and walks itself
+// to the finish. Native flows skip the sign-in (access is the Eleven account)
+// and the photo step, so the primary button is Save, not Sign in: welcome asks
+// only the name; setup adds the space name, email, invites, and the extras.
+// Shared logic here; the hosts (ElevenChatScreen / ElevenConversationPane)
+// present it as each platform's sheet.
+public struct ElevenQuickFormView: View {
+ @ObservedObject var model: ElevenConversationModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var name: String
+ @State private var spaceName: String
+ @State private var email = ""
+ @State private var membersInvite = true
+ @State private var ai = true
+ @State private var bots = true
+ @State private var apps = true
+ @State private var error: String?
+ @State private var saving = false
+
+ public init(model: ElevenConversationModel) {
+ self.model = model
+ _name = State(initialValue: model.instance.screenName)
+ _spaceName = State(initialValue: model.quickFormSpaceName)
+ }
+
+ private var title: String {
+ model.quickFormIsSetup ? "Set up your space" : "Introduce yourself"
+ }
+
+ public var body: some View {
+ #if os(macOS)
+ // Mac sheet chrome: title, the grouped form, then the button row —
+ // Cancel left, the prominent default action right.
+ VStack(spacing: 0) {
+ Text(title)
+ .font(.headline)
+ .padding(.top, 14)
+ .padding(.bottom, 4)
+ formBody
+ .formStyle(.grouped)
+ Divider()
+ HStack {
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ Button("Save", action: submit)
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .disabled(saving)
+ }
+ .padding(12)
+ }
+ .frame(minWidth: 400, minHeight: model.quickFormIsSetup ? 540 : 220)
+ #else
+ NavigationStack {
+ formBody
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save", action: submit)
+ .disabled(saving)
+ }
+ }
+ }
+ #endif
+ }
+
+ private var formBody: some View {
+ Form {
+ Section {
+ TextField("Your name", text: $name)
+ .accessibilityLabel("Your name")
+ }
+ if model.quickFormIsSetup {
+ Section("Your space") {
+ TextField("Space name", text: $spaceName)
+ .accessibilityLabel("Space name")
+ TextField("Contact email (optional)", text: $email)
+ .accessibilityLabel("Contact email (optional)")
+ #if os(iOS)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ #endif
+ }
+ Section {
+ Toggle("Members can invite people", isOn: $membersInvite)
+ } footer: {
+ Text("Otherwise, new faces only arrive through you.")
+ }
+ Section("Extras") {
+ VStack(alignment: .leading, spacing: 3) {
+ Toggle("AI", isOn: $ai)
+ // The one-line reassurance, right under the first (and
+ // scariest-sounding) switch.
+ Text("Everything essential works with any of these off — change your mind anytime in Admin.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ Toggle("Bots", isOn: $bots)
+ Toggle("Apps", isOn: $apps)
+ }
+ }
+ if let error {
+ // Inline, occupying layout — never a floating overlay; the
+ // words carry the state (color is decoration, not the signal).
+ Section {
+ Text(error)
+ .font(.callout)
+ .foregroundStyle(.red)
+ .accessibilityLabel("Error: \(error)")
+ }
+ }
+ }
+ }
+
+ private func submit() {
+ // The same required checks the conversational steps enforce.
+ if cleanNameAnswer(name).isEmpty {
+ error = "Add your name to continue."
+ return
+ }
+ if model.quickFormIsSetup, spaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ error = "Give your space a name to continue."
+ return
+ }
+ saving = true
+ var v = QuickFormValues()
+ v.name = name
+ v.spaceName = spaceName
+ v.email = email
+ v.membersInvite = membersInvite
+ v.ai = ai
+ v.bots = bots
+ v.apps = apps
+ Task { @MainActor in
+ if let msg = await model.applyQuickForm(v) {
+ error = msg
+ saving = false
+ } else {
+ dismiss()
+ }
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
index ccc496a..c1dde4d 100644
--- a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
+++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
@@ -181,16 +181,20 @@ func cleanNameAnswer(_ raw: String) -> String {
/// One tappable chip on a bubble. `spentLabel` is its past-tense form once
/// tapped; `isStale` ties liveness to the step (finish-line chips stay live).
+/// `trailing` right-aligns the chip — the quiet alternative path (the quick
+/// form) sits apart from the step's primary answers.
@MainActor
public final class FlowChip: Identifiable {
public let id = UUID()
public let label: String
public let spentLabel: String?
+ public let trailing: Bool
public let isStale: () -> Bool
public let run: () -> Void
- init(label: String, spentLabel: String?, isStale: @escaping () -> Bool, run: @escaping () -> Void) {
+ init(label: String, spentLabel: String?, trailing: Bool = false, isStale: @escaping () -> Bool, run: @escaping () -> Void) {
self.label = label
self.spentLabel = spentLabel
+ self.trailing = trailing
self.isStale = isStale
self.run = run
}
@@ -449,6 +453,7 @@ public final class FlowEngine {
return FlowChip(
label: renderText(c["label"]),
spentLabel: c["spent"].stringValue,
+ trailing: c["trailing"].truthy,
isStale: (!advancing && !hasProbe) ? { false } : { go.taken },
run: { [weak self] in
guard let self else { return }
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 8ec2e48..69a5247 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -46,6 +46,16 @@
"hint": "e.g. Finn Wolfhard"
}
],
+ "chips": [
+ {
+ "label": "Fill in a form instead",
+ "trailing": true,
+ "advancing": false,
+ "effect": {
+ "verb": "openQuickForm"
+ }
+ }
+ ],
"input": {
"kind": "text",
"required": true,
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index 54f4f4e..577b83a 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -109,6 +109,14 @@
},
"ack": "No problem — I'll nudge you again in a day or so.",
"goto": "name"
+ },
+ {
+ "label": "Fill in a form instead",
+ "trailing": true,
+ "advancing": false,
+ "effect": {
+ "verb": "openQuickForm"
+ }
}
]
},
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
index 8d36bbf..38ce7a6 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
@@ -130,6 +130,11 @@ final class FlowEngineTests: XCTestCase {
await io.tap("Skip for now") // email; the photo step auto-skips under native
XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" })
await io.tap("Members can invite people")
+ // The extras trio (mirrors the JS native run): the one-tap common path
+ // records and writes no gates — defaults are already on server-side.
+ await io.tap("Keep everything on")
+ XCTAssertTrue(io.records.contains { $0.step == "extras" && $0.value == "all-on" })
+ XCTAssertFalse(io.verbLog.contains { $0.0 == "setFeature" })
// Keys: never pitched — recorded as covered by the Eleven account.
XCTAssertFalse(io.transcript.contains { $0.text.contains("Now the most important thing of all") })
@@ -210,6 +215,47 @@ final class FlowEngineTests: XCTestCase {
XCTAssertTrue(io.ended == false) // onEnd fires on env, not io — checked below
}
+ // MARK: the quick-form fast path (mirrors the JS trailing-chip test)
+
+ func testQuickFormTrailingChip() async throws {
+ // A web-shaped env (native=false): the access step renders its chips —
+ // including the trailing quick-form chip, whose tap fires the
+ // openQuickForm verb WITHOUT consuming the step.
+ let flow = try moduleFlow("welcome")
+ let io = Harness()
+ let env = nativeEnv()
+ env.flags["native"] = .bool(false)
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ let chip = io.lastChips?.first { $0.label == "Fill in a form instead" }
+ XCTAssertNotNil(chip, "the welcome access step offers the quick-form chip")
+ XCTAssertEqual(chip?.trailing, true, "flagged trailing — the renderer right-aligns it")
+ XCTAssertEqual(io.lastChips?.first { $0.label == "Sign in (recommended)" }?.trailing, false,
+ "primary chips stay untagged")
+ await io.tap("Fill in a form instead")
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "openQuickForm" })
+ await io.tap("Sign in (recommended)") // non-advancing: the step is still live
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" })
+
+ // Setup: the name step carries the same chip.
+ let flow2 = try moduleFlow("setup")
+ let io2 = Harness()
+ let env2 = nativeEnv()
+ env2.flags["native"] = .bool(false)
+ let engine2 = FlowEngine(flow: flow2, env: env2, io: io2)
+ engine2.start()
+ await io2.settle()
+ defer { withExtendedLifetime(engine2) {} }
+ let chip2 = io2.lastChips?.first { $0.label == "Fill in a form instead" }
+ XCTAssertNotNil(chip2, "the setup name step offers the quick-form chip")
+ XCTAssertEqual(chip2?.trailing, true)
+ await io2.tap("Fill in a form instead")
+ XCTAssertTrue(io2.verbLog.contains { $0.0 == "openQuickForm" })
+ }
+
// MARK: resume semantics
func testResumeSkipsAnsweredSteps() async throws {
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift
index 6b3b90e..f9fb638 100644
--- a/ios/LChatMac/ElevenConversationPane.swift
+++ b/ios/LChatMac/ElevenConversationPane.swift
@@ -12,6 +12,7 @@ struct ElevenConversationPane: View {
@EnvironmentObject private var app: MacAppState
@State private var model: ElevenConversationModel?
@State private var showDiscover = false
+ @State private var showQuickForm = false
@AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
@AppStorage("nativeTheme") private var themeName = "classic"
@@ -43,10 +44,18 @@ struct ElevenConversationPane: View {
}
.frame(minWidth: 420, minHeight: 420)
}
+ .sheet(isPresented: $showQuickForm) {
+ // "Fill in a form instead" — the whole conversation's questions at
+ // once; submitting fast-fills the flow's records and it resumes.
+ if let model {
+ ElevenQuickFormView(model: model)
+ }
+ }
.onAppear {
guard model == nil else { return }
let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
myUserId: store.myUserId)
+ m?.openQuickForm = { showQuickForm = true }
m?.openThread = { id in selectedID = id }
m?.openAdmin = {
Task { @MainActor in
diff --git a/package.json b/package.json
index 88b1762..f9980c7 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index ded5377..ba88d85 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -156,9 +156,11 @@ await test("setup: the full first-run transcript, happy path with detours", asyn
assert.equal(h.records[0].step, "name");
// Group: ack folded into the context bubble; no Keep chip (nameSet=false).
+ // (lastChips still holds the name step's quick-form chip — a chipless say
+ // doesn't reset it — so check the Keep chip's absence, not emptiness.)
const group = h.transcript.find((t) => t.extra.key === "q-group");
assert.match(group.text, /^Lovely to meet you, A{32}! 🤝\n\nNext, this space needs a name/);
- assert.equal(h.lastChips, null, "no Keep chip on a fresh instance");
+ assert.equal((h.lastChips || []).some((c) => /^Keep/.test(c.label)), false, "no Keep chip on a fresh instance");
// Rejected name says the honest error and re-asks; accepted name records.
await h.answer("X");
@@ -403,6 +405,35 @@ const welcomeEnv = (over = {}) => ({
...over,
});
+// The quick-form fast path: both conversations' FIRST interactive step offers
+// a right-aligned "Fill in a form instead" chip whose tap opens the host form
+// (the openQuickForm verb) WITHOUT consuming the step — the conversation stays
+// answerable while the form is up (Cancel walks away scot-free).
+await test("quick form: the trailing fast-path chip on welcome access + setup name", async () => {
+ const h = harness({ envExtra: welcomeEnv() });
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+ const chip = (h.lastChips || []).find((c) => c.label === "Fill in a form instead");
+ assert.ok(chip, "the welcome access step offers the quick-form chip");
+ assert.equal(chip.trailing, true, "flagged trailing — the renderer right-aligns it");
+ assert.equal((h.lastChips || []).find((c) => c.label === "Sign in (recommended)").trailing, undefined, "primary chips stay untagged");
+ await h.tap("Fill in a form instead");
+ assert.ok(h.verbLog.find((v) => v[0] === "openQuickForm"), "the tap fires the openQuickForm verb");
+ await h.tap("Sign in (recommended)"); // non-advancing: the step is still live after peeking at the form
+ assert.ok(h.verbLog.find((v) => v[0] === "savePasskey"));
+
+ const h2 = harness();
+ runFlow(SETUP_FLOW, h2.env, h2.io);
+ await tick();
+ const chip2 = (h2.lastChips || []).find((c) => c.label === "Fill in a form instead");
+ assert.ok(chip2, "the setup name step offers the quick-form chip");
+ assert.equal(chip2.trailing, true);
+ await h2.tap("Fill in a form instead");
+ assert.ok(h2.verbLog.find((v) => v[0] === "openQuickForm"));
+ await h2.answer("Paul"); // the typed answer still lands after the chip tap
+ assert.deepEqual(h2.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "Paul"]);
+});
+
await test("welcome: invited fresh join — passkey probe, suggested name, photo nokey retry", async () => {
let photoTries = 0;
const h = harness({
diff --git a/test/quickform.test.mjs b/test/quickform.test.mjs
new file mode 100644
index 0000000..e32a53b
--- /dev/null
+++ b/test/quickform.test.mjs
@@ -0,0 +1,92 @@
+// 🤖 The quick-form planner: form values → the exact verbs + step records the
+// conversational path would produce. The records here are pinned against the
+// flow documents' own labels — if a flow's record copy changes, this test (and
+// the planner) must change with it, or the two paths' transcripts diverge.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { planQuickForm } from "../web/static/apps/quickform.js";
+import SETUP_FLOW from "../web/static/apps/setup.flow.js";
+import WELCOME_FLOW from "../web/static/apps/welcome.flow.js";
+
+await test("welcome plan: cleaned name + the photo skip record", () => {
+ // The same clean-before-cap rule as the typed answer ("I'm Dermot" → Dermot).
+ const p = planQuickForm("welcome", { name: " Hi, I'm Dermot! " });
+ assert.equal(p.name, "Dermot");
+ assert.deepEqual(p.verbs, [{ verb: "setMyName", args: ["Dermot"] }]);
+ assert.deepEqual(p.records, [
+ { step: "name", label: "noted: you're Dermot", value: "Dermot" },
+ { step: "photo", label: "skipped the profile photo", value: "skipped" },
+ ]);
+});
+
+await test("welcome plan: the 32-char cap holds after cleaning", () => {
+ const p = planQuickForm("welcome", { name: "my name is " + "A".repeat(40) });
+ assert.equal(p.name, "A".repeat(32));
+});
+
+await test("setup plan, everything on: no setFeature writes, the one-tap extras record", () => {
+ const p = planQuickForm("setup", {
+ name: "Paul",
+ spaceName: " Team Awesome ",
+ email: "paul@example.com",
+ invites: true,
+ ai: true,
+ bots: true,
+ apps: true,
+ });
+ assert.deepEqual(p.verbs, [
+ { verb: "setMyName", args: ["Paul"] },
+ { verb: "setGroupName", args: ["Team Awesome"] },
+ { verb: "setContactEmail", args: ["paul@example.com"] },
+ { verb: "setUserInvites", args: [true] },
+ ]);
+ assert.deepEqual(p.records, [
+ { step: "name", label: "noted: you're Paul", value: "Paul" },
+ { step: "group", label: "named this space “Team Awesome”", value: "Team Awesome" },
+ { step: "email", label: "noted a contact email", value: "paul@example.com" },
+ { step: "photo", label: "skipped the space photo", value: "skipped" },
+ { step: "invites", label: "decided members can invite people", value: "members" },
+ { step: "extras", label: "kept all the extras on", value: "all-on" },
+ ]);
+});
+
+await test("setup plan, granular extras + skipped email + admin-only invites", () => {
+ const p = planQuickForm("setup", {
+ name: "Paul",
+ spaceName: "Team Awesome",
+ email: " ",
+ invites: false,
+ ai: false,
+ bots: true,
+ apps: false,
+ });
+ assert.equal(p.verbs.some((v) => v.verb === "setContactEmail"), false, "an empty email writes nothing");
+ assert.deepEqual(p.verbs.filter((v) => v.verb === "setFeature"), [
+ { verb: "setFeature", args: ["ai", false] },
+ { verb: "setFeature", args: ["bots", true] },
+ { verb: "setFeature", args: ["apps", false] },
+ ]);
+ assert.deepEqual(p.verbs.find((v) => v.verb === "setUserInvites"), { verb: "setUserInvites", args: [false] });
+ const by = Object.fromEntries(p.records.map((r) => [r.step, r]));
+ assert.deepEqual(by.email, { step: "email", label: "skipped the contact email", value: "skipped" });
+ assert.deepEqual(by.invites, { step: "invites", label: "decided only the admin invites people", value: "admin only" });
+ assert.deepEqual(by["extras-ai"], { step: "extras-ai", label: "switched AI off", value: "off" });
+ assert.deepEqual(by["extras-bots"], { step: "extras-bots", label: "enabled bots", value: "on" });
+ assert.deepEqual(by["extras-apps"], { step: "extras-apps", label: "switched apps off", value: "off" });
+ assert.equal(by.extras, undefined, "the one-tap record only when everything stays on");
+});
+
+// The crucial invariant behind the fast-fill: every planned record names a
+// real step of its flow, so the resume machinery recognises them all.
+await test("planned record steps all exist in their flow documents", () => {
+ for (const [kind, flow, values] of [
+ ["welcome", WELCOME_FLOW, { name: "Paul" }],
+ ["setup", SETUP_FLOW, { name: "Paul", spaceName: "X", email: "a@b.c", ai: false, bots: false, apps: false }],
+ ["setup", SETUP_FLOW, { name: "Paul", spaceName: "X", email: "" }],
+ ]) {
+ const ids = new Set(flow.steps.map((s) => s.id));
+ for (const r of planQuickForm(kind, values).records) {
+ assert.ok(ids.has(r.step), `${kind}: record for unknown step ${r.step}`);
+ }
+ }
+});
diff --git a/web/static/apps.js b/web/static/apps.js
index dab86ad..9977d03 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -1527,13 +1527,23 @@ function fillFullNotice(row, spec = {}, opts = {}) {
// a.stale() is the script's step guard) renders spent: disabled, and the
// one that was actually tapped shows its past-tense label ("Skipped").
const stale = !!(act.stale && act.stale());
- btn.className = "action-chip" + (stale ? " action-chip--spent" : "");
+ btn.className =
+ "action-chip" +
+ (stale ? " action-chip--spent" : "") +
+ // The FIRST trailing chip carries the auto margin that pushes the
+ // trailing run to the row's end; the rest just follow it.
+ (act.trailingFirst ? " action-chip--trailing" : "");
btn.textContent = stale && act.tapped ? act.done || act.label : act.label;
btn.disabled = stale;
};
const buttons = [];
+ let trailingSeen = false;
for (const a of actions) {
if (!a || !a.label) continue;
+ if (a.trailing && !trailingSeen) {
+ trailingSeen = true;
+ a.trailingFirst = true;
+ }
const b = document.createElement("button");
b.type = "button";
paint(b, a);
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js
index 71387ad..b9c6f99 100644
--- a/web/static/apps/flow.js
+++ b/web/static/apps/flow.js
@@ -211,6 +211,9 @@ export function runFlow(flow, env, io) {
return (stepCtx.step.chips || []).filter((c) => evalCond(env, c.if)).map((c) => ({
label: renderText(env, c.label),
done: c.spent,
+ // A trailing chip renders right-aligned — the quiet alternative path
+ // (the quick form) sits apart from the step's primary answers.
+ trailing: c.trailing ? true : undefined,
// Finish-line chips (advancing:false, no probe) stay live forever.
stale: c.advancing === false && !c.probe ? undefined : () => go.taken,
run: () => {
diff --git a/web/static/apps/quickform.js b/web/static/apps/quickform.js
new file mode 100644
index 0000000..e208122
--- /dev/null
+++ b/web/static/apps/quickform.js
@@ -0,0 +1,76 @@
+// 🤖 The quick-form planner — the pure half of "Fill in a form instead".
+// Submitting the form must leave EXACTLY the state the conversational path
+// would: the same bridge verbs run, the same step records emitted (labels and
+// values copied verbatim from setup.flow.js / welcome.flow.js, so transcript
+// lines read identically), and the flow's own resume machinery then carries
+// the run to the sign-in step. This module only PLANS — no DOM, no network —
+// so the mapping from form values to verbs+records is unit-testable in Node;
+// the host form (shell.js) and its tests consume the same plan.
+import { cleanNameAnswer } from "./flow.js";
+
+// planQuickForm(kind, values) → { name, verbs: [{verb, args}], records:
+// [{step, label, value}] } — verbs in run order, records in the order the
+// conversation would have emitted them. `name` is the cleaned/capped screen
+// name (the flow's clean-before-cap rule), echoed back so the form can show
+// what will actually be saved.
+//
+// kind = "welcome": values = { name }
+// kind = "setup": values = { name, spaceName, email, invites, ai, bots, apps }
+export function planQuickForm(kind, values) {
+ const v = values || {};
+ // Clean BEFORE the cap, exactly like the typed name answer (flow.js).
+ const name = cleanNameAnswer((v.name || "").trim()).slice(0, 32);
+ const verbs = [{ verb: "setMyName", args: [name] }];
+ const records = [{ step: "name", label: `noted: you're ${name}`, value: name }];
+
+ if (kind === "welcome") {
+ // The photo is a form no-op — recorded as skipped (a skip is an answer;
+ // an unrecorded step would replay on the resume the form depends on).
+ records.push({ step: "photo", label: "skipped the profile photo", value: "skipped" });
+ return { name, verbs, records };
+ }
+
+ const spaceName = (v.spaceName || "").trim();
+ verbs.push({ verb: "setGroupName", args: [spaceName] });
+ records.push({ step: "group", label: `named this space “${spaceName}”`, value: spaceName });
+
+ const email = (v.email || "").trim();
+ if (email) {
+ verbs.push({ verb: "setContactEmail", args: [email] });
+ records.push({ step: "email", label: "noted a contact email", value: email });
+ } else {
+ records.push({ step: "email", label: "skipped the contact email", value: "skipped" });
+ }
+
+ records.push({ step: "photo", label: "skipped the space photo", value: "skipped" });
+
+ const invites = v.invites !== false;
+ verbs.push({ verb: "setUserInvites", args: [invites] });
+ records.push(
+ invites
+ ? { step: "invites", label: "decided members can invite people", value: "members" }
+ : { step: "invites", label: "decided only the admin invites people", value: "admin only" },
+ );
+
+ // The extras trio mirrors the conversation's two paths: everything on is the
+ // one-tap record (defaults are already on server-side — writing nothing is
+ // the point), anything off walks the granular records, writing each gate.
+ const extras = [
+ ["ai", v.ai !== false, "AI"],
+ ["bots", v.bots !== false, "bots"],
+ ["apps", v.apps !== false, "apps"],
+ ];
+ if (extras.every(([, on]) => on)) {
+ records.push({ step: "extras", label: "kept all the extras on", value: "all-on" });
+ } else {
+ for (const [key, on, noun] of extras) {
+ verbs.push({ verb: "setFeature", args: [key, on] });
+ records.push({
+ step: `extras-${key}`,
+ label: on ? `enabled ${noun}` : `switched ${noun} off`,
+ value: on ? "on" : "off",
+ });
+ }
+ }
+ return { name, verbs, records };
+}
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index 2586517..8469811 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -34,6 +34,17 @@ export default {
bubbles: [
{ text: "First — what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" },
],
+ // The fast path for admins in a hurry: one form with the whole setup at
+ // once, ending in the same sign-in (the keys step's settle picks the
+ // ceremony's return up from the fast-filled records).
+ chips: [
+ {
+ label: "Fill in a form instead",
+ trailing: true,
+ advancing: false,
+ effect: { verb: "openQuickForm" },
+ },
+ ],
input: { kind: "text", required: true, maxLen: 32, clean: "name" },
effect: { verb: "setMyName", args: ["$answer"] },
on: {
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js
index 4aaa164..d80cc78 100644
--- a/web/static/apps/setup.js
+++ b/web/static/apps/setup.js
@@ -82,6 +82,10 @@ function verb(name, args, env) {
return bridge.deferBackupReminders && bridge.deferBackupReminders();
case "openAdmin":
return openAppThread("admin");
+ case "openQuickForm":
+ // The name step's fast path — the host owns the form sheet (web:
+ // openSetupQuickForm in shell.js; native: the quick-form sheet).
+ return env.openQuickForm && env.openQuickForm();
case "startTour":
// Reached by tapping "Take the tour" at the end of setup — the user has
// just spent minutes with Eleven, so skip the tour's own "Hi, fancy a
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js
index 57899e0..302ed88 100644
--- a/web/static/apps/welcome.flow.js
+++ b/web/static/apps/welcome.flow.js
@@ -84,6 +84,16 @@ export default {
ack: "No problem — I'll nudge you again in a day or so.",
goto: "name",
},
+ // The fast path for people in a hurry: one form with every question at
+ // once, ending in the same sign-in. Lives on this step deliberately —
+ // its settle (bridge.enrolled) is what carries the flow on after the
+ // form's fast-filled records + sign-in hand-off.
+ {
+ label: "Fill in a form instead",
+ trailing: true,
+ advancing: false,
+ effect: { verb: "openQuickForm" },
+ },
],
},
{
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
index aece623..8345a2b 100644
--- a/web/static/apps/welcome.js
+++ b/web/static/apps/welcome.js
@@ -76,6 +76,10 @@ function makeVerb(env) {
// "Browse open groups" on the finish step — the host supplies the
// directory surface (web: showDiscover; native: the Discover sheet).
return env.openDiscover && env.openDiscover();
+ case "openQuickForm":
+ // The access step's fast path — the host owns the form sheet (web:
+ // openWelcomeQuickForm in shell.js; native: the quick-form sheet).
+ return env.openQuickForm && env.openQuickForm();
case "startTour":
// Reached by "Take the tour" at the end of the welcome — the user has
// just been talking to Eleven, so skip the tour's own "fancy a tour?"
diff --git a/web/static/shell.js b/web/static/shell.js
index 7d415a9..70e2f6e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -66,8 +66,10 @@ import {
setThreadOpener,
setHostBridge,
setSpaceInfo,
+ spaceInfo,
setElevenTour,
setSetupBridge,
+ setupBridge,
runCommand,
commandCatalog,
buildLocalNotice,
@@ -115,6 +117,7 @@ import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring
import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app)
import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app)
+import { planQuickForm } from "/static/apps/quickform.js"; // "Fill in a form instead" — values → verbs+records
import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip)
import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md)
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js";
@@ -4388,6 +4391,7 @@ async function maybeStartSetup(mePromise) {
startSetup({
threadId: id,
live,
+ openQuickForm: () => openSetupQuickForm(id),
typing: (on) => {
setNoticeTyping(id, "Eleven", on);
if (on) pinFeed();
@@ -4546,6 +4550,7 @@ async function maybeStartWelcome() {
openPendingThread,
openGroups,
openDiscover: () => showDiscover(),
+ openQuickForm: () => openWelcomeQuickForm(id, (pending.suggested || "").slice(0, 32)),
typing: (on) => {
setNoticeTyping(id, "Eleven", on);
if (on) pinFeed();
@@ -4555,6 +4560,269 @@ async function maybeStartWelcome() {
} catch {}
}
+// ---- the quick forms ("Fill in a form instead") ------------------------------
+// The onboarding conversations' fast path: one modal form with every question
+// at once, ending in the same Sign in. Submitting FAST-FILLS the flow's own
+// records — the same bridge verbs and the same step records (labels verbatim —
+// planQuickForm) the conversational path would produce — so the flow's resume
+// machinery does the rest: after the sign-in ceremony the run re-enters, skips
+// the answered steps, and settles through the access/keys step like any other
+// return leg. Escape/Cancel closes without touching the conversation.
+
+// One labelled text field (the shared .eleven-input surface).
+function quickFormField(parent, { id, label, value = "", hint = "", maxLength, type = "text" }) {
+ const lab = document.createElement("label");
+ lab.className = "qf-label";
+ lab.htmlFor = id;
+ lab.textContent = label;
+ const input = document.createElement("input");
+ input.id = id;
+ input.type = type;
+ input.className = "eleven-input sheet-input";
+ input.value = value;
+ if (hint) input.placeholder = hint;
+ if (maxLength) input.maxLength = maxLength;
+ parent.append(lab, input);
+ return input;
+}
+
+// One toggle row (the shared .opt-row recipe: the what, then the so-what).
+function quickFormToggle(parent, { id, label, desc = "", checked = true }) {
+ const row = document.createElement("label");
+ row.className = "opt-row";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.id = id;
+ cb.checked = checked;
+ const txt = document.createElement("span");
+ txt.textContent = label;
+ if (desc) {
+ const d = document.createElement("span");
+ d.className = "opt-desc";
+ d.textContent = desc;
+ txt.appendChild(d);
+ }
+ row.append(cb, txt);
+ parent.appendChild(row);
+ return cb;
+}
+
+// The shared sheet skeleton: title, caller-filled body, an inline error line
+// (inline, occupying layout — never a floating toast), then Sign in / Cancel.
+function quickFormSheet(title) {
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = title;
+ head.appendChild(h);
+ card.appendChild(head);
+ const error = document.createElement("p");
+ error.className = "qf-error";
+ error.setAttribute("role", "alert"); // announced when the text lands
+ error.hidden = true;
+ const row = document.createElement("div");
+ row.className = "row";
+ const submit = document.createElement("button");
+ submit.textContent = "Sign in";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove(); // the conversation stays untouched
+ row.append(submit, cancel);
+ return {
+ backdrop,
+ card,
+ submit,
+ // Appended by the caller once its fields are in, keeping field → error →
+ // buttons in reading (and tab) order.
+ finish: () => card.append(error, row),
+ setError: (msg) => {
+ error.textContent = msg;
+ error.hidden = !msg;
+ },
+ };
+}
+
+// Run the plan: verbs through the SAME setup bridge the conversation uses
+// (only the flow's own `on`-branched verbs — group name, email — can push
+// back), then the step records, then the sign-in hand-off. Returns null on
+// success or the inline error message to show.
+async function submitQuickForm(kind, threadId, values, backdrop) {
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const plan = planQuickForm(kind, values);
+ const bridge = setupBridge();
+ for (const v of plan.verbs) {
+ let r;
+ try {
+ r = await bridge[v.verb](...v.args);
+ } catch {
+ r = "error";
+ }
+ // The two verbs the conversation branches on push back here the same way
+ // (setup.flow.js `on.invalid`); the rest are fire-and-forget, as in the flow.
+ if (v.verb === "setGroupName" && r !== "ok") {
+ return r === "invalid"
+ ? "That space name didn't stick — it needs to be 1–60 characters."
+ : "Something went wrong saving the space name — try again in a moment.";
+ }
+ if (v.verb === "setContactEmail" && r !== "ok") {
+ return r === "invalid"
+ ? "That doesn't look like an email address — fix it, or leave it empty."
+ : "Something went wrong saving the email — try again in a moment.";
+ }
+ }
+ // The records, through the same emit path as io.record — skipping steps the
+ // conversation already recorded (the flow never re-records a done step).
+ const type = kind + ".step";
+ const stepDone = (step) => {
+ const st = appStateFor(threadId, kind);
+ return !!(st && st.steps && st.steps[step] && st.steps[step].done);
+ };
+ const records = plan.records.filter((r) => !stepDone(r.step));
+ for (const r of records) {
+ // Same send-retry discipline as makeSay: the socket may still be connecting.
+ let sent = false;
+ for (let i = 0; i < 12 && !sent; i++) {
+ sent = await emitAppEvent(threadId, { type, step: r.step, label: r.label, value: r.value });
+ if (!sent) await sleep(150);
+ }
+ if (!sent) return "Couldn't save that — check your connection and try again.";
+ }
+ // Records travel over the WebSocket: wait for the echoes to fold into the
+ // app state BEFORE the sign-in navigation, or a same-tab redirect can outrun
+ // them and lose the fast-fill (capped — a lost echo mustn't wedge the form).
+ for (let i = 0; i < 20 && !records.every((r) => stepDone(r.step)); i++) await sleep(100);
+ backdrop.remove();
+ // Stay-put contexts (the native wrapper, an installed PWA) run the ceremony
+ // elsewhere and keep this page alive — reload it shortly after: the live
+ // flow engine is parked mid-step (its armed ask and settle belong to the
+ // step the form just answered), so a resume over the fast-filled records is
+ // the one honest re-entry. The reloaded flow skips the answered steps and
+ // sits on the sign-in step, whose settle poll (plus the enrolment completion
+ // poller) finishes the job when the ceremony completes. Same-tab contexts
+ // navigate away inside savePasskey — the pagehide guard keeps this timer
+ // from firing under them.
+ let left = false;
+ addEventListener("pagehide", () => (left = true), { once: true });
+ setTimeout(() => {
+ if (!left && document.visibilityState === "visible") location.reload();
+ }, 1200);
+ bridge.savePasskey();
+ return null;
+}
+
+// The joiner's quick form: just the name, then Sign in.
+function openWelcomeQuickForm(threadId, suggested) {
+ const sheet = quickFormSheet("Introduce yourself");
+ const name = quickFormField(sheet.card, {
+ id: "qf-name",
+ label: "Your name",
+ value: suggested || "",
+ hint: "e.g. Finn Wolfhard",
+ maxLength: 32,
+ });
+ const note = document.createElement("p");
+ note.className = "sub qf-note";
+ note.textContent = "You'll be brought straight back here.";
+ sheet.card.appendChild(note);
+ sheet.finish();
+ sheet.submit.onclick = async () => {
+ if (!name.value.trim()) {
+ sheet.setError("Add your name to continue.");
+ name.focus();
+ return;
+ }
+ sheet.submit.disabled = true;
+ const err = await submitQuickForm("welcome", threadId, { name: name.value }, sheet.backdrop);
+ if (err) {
+ sheet.setError(err);
+ sheet.submit.disabled = false;
+ }
+ };
+}
+
+// The admin's quick form: the whole setup at once, then Sign in.
+async function openSetupQuickForm(threadId) {
+ // The current space name first (the flow's "Keep" affordance becomes a prefill).
+ const space = await spaceInfo();
+ const sheet = quickFormSheet("Set up your space");
+ const name = quickFormField(sheet.card, {
+ id: "qf-name",
+ label: "Your name",
+ value: session.name || "",
+ hint: "e.g. Finn Wolfhard",
+ maxLength: 32,
+ });
+ const spaceName = quickFormField(sheet.card, {
+ id: "qf-space",
+ label: "Space name",
+ value: space && space.nameSet ? space.name || "" : "",
+ hint: "e.g. The Book Club",
+ maxLength: 60,
+ });
+ const email = quickFormField(sheet.card, {
+ id: "qf-email",
+ label: "Contact email (optional)",
+ hint: "e.g. finn@example.com",
+ type: "email",
+ });
+ const invites = quickFormToggle(sheet.card, {
+ id: "qf-invites",
+ label: "Members can invite people",
+ desc: "Otherwise, new faces only arrive through you.",
+ });
+ const extras = document.createElement("div");
+ extras.setAttribute("role", "group");
+ extras.setAttribute("aria-labelledby", "qf-extras-label");
+ const gh = document.createElement("p");
+ gh.id = "qf-extras-label";
+ gh.className = "qf-label";
+ gh.textContent = "Extras";
+ extras.appendChild(gh);
+ const ai = quickFormToggle(extras, {
+ id: "qf-ai",
+ label: "AI",
+ desc: "Everything essential works with any of these off — change your mind anytime in Admin.",
+ });
+ const bots = quickFormToggle(extras, { id: "qf-bots", label: "Bots" });
+ const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" });
+ sheet.card.appendChild(extras);
+ sheet.finish();
+ sheet.submit.onclick = async () => {
+ if (!name.value.trim()) {
+ sheet.setError("Add your name to continue.");
+ name.focus();
+ return;
+ }
+ if (!spaceName.value.trim()) {
+ sheet.setError("Give your space a name to continue.");
+ spaceName.focus();
+ return;
+ }
+ sheet.submit.disabled = true;
+ const err = await submitQuickForm(
+ "setup",
+ threadId,
+ {
+ name: name.value,
+ spaceName: spaceName.value,
+ email: email.value,
+ invites: invites.checked,
+ ai: ai.checked,
+ bots: bots.checked,
+ apps: apps.checked,
+ },
+ sheet.backdrop,
+ );
+ if (err) {
+ sheet.setError(err);
+ sheet.submit.disabled = false;
+ }
+ };
+}
+
// Send a normal chat message (name + text) into a thread, as if typed. Same
// envelope + encryption as the composer and the app-event transport; used by the
// tour to ask Eleven a question on the member's behalf.
diff --git a/web/static/style.css b/web/static/style.css
index 9681a1e..46f551e 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -942,6 +942,10 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* A spent chip (its step was answered/skipped): a quiet part of the record,
not something that still looks tappable. */
.action-chip--spent { background: transparent; border-color: transparent; padding-left: 6px; padding-right: 6px; }
+/* A trailing chip run (the quiet alternative path, e.g. the quick form) sits at
+ the row's far end: auto inline-start margin on its first chip pushes it over
+ (RTL-safe), or onto its own right-aligned line when the row wraps. */
+.action-chip--trailing { margin-inline-start: auto; }
/* Local notice: a command's private "only visible to you" reply — a client-only
feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no
@@ -2144,6 +2148,12 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
.sheet-head h2 { margin: 0; }
.linkish { background: transparent; color: var(--link); padding: 6px 8px; font-weight: 600; }
.sheet-input { margin-bottom: 10px; }
+/* The quick forms ("Fill in a form instead"): labelled fields over the shared
+ sheet inputs, an Extras group heading, and inline (never floating) errors. */
+.qf-label { display: block; font-size: 13px; font-weight: 600; color: var(--ink); margin: 6px 2px 4px; }
+.qf-note { margin: 2px 2px 0; }
+.qf-error { margin: 8px 2px 0; font-size: 14px; color: var(--danger); }
+.qf-error[hidden] { display: none; }
/* full-bleed list inside the sheet: rows span the card edges (which pads 28px) */
.pick-list { margin: 4px -28px 0; max-height: 56vh; overflow-y: auto; }
/* Picker sheets (new chat / add people) had nested scrolling — the sheet AND the