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>
22 files changed,
+932
−29
changelog/2026-07-11-quick-form.md+5 −0ios/LChat/Views/ElevenChatScreen.swift+9 −0ios/LChatCore/Sources/LChatCore/ElevenChatView.swift+45 −21ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+118 −4ios/LChatCore/Sources/LChatCore/ElevenQuickFormView.swift+154 −0ios/LChatCore/Sources/LChatCore/FlowEngine.swift+6 −1ios/LChatCore/Sources/LChatCore/Flows/setup.json+10 −0ios/LChatCore/Sources/LChatCore/Flows/welcome.json+8 −0ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift+46 −0ios/LChatMac/ElevenConversationPane.swift+9 −0package.json+1 −1test/flow.test.mjs+32 −1test/quickform.test.mjs+92 −0web/static/apps.js+11 −1web/static/apps/flow.js+3 −0web/static/apps/quickform.js+76 −0web/static/apps/setup.flow.js+11 −0web/static/apps/setup.js+4 −0web/static/apps/welcome.flow.js+10 −0web/static/apps/welcome.js+4 −0web/static/shell.js+268 −0web/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 |