// One Eleven + the quick forms: the scripted setup/welcome conversations and // their "Fill in a form instead" fast path. The scripted flows and the live // agent share a thread — this module bridges the flow engine (setup.flow.js / // welcome.flow.js) into the chat app and turns a submitted form into the same // recorded steps the conversation would have produced. Extracted verbatim // from shell.js; behaviours ride the verbs seam. import { verbs } from "/static/shellverbs.js"; import { session, roster, myUserId, threads, threadKeys, activeId, threadName, embed, ws, instanceKey, threadApp, pendingJoinThread } from "/static/shellstate.js"; import { encrypt } from "/static/crypto.js"; import { appStateFor, setupBridge, spaceInfo, emitAppEvent } from "/static/apps.js"; import { appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js"; import { planQuickForm } from "/static/quickform.js"; import { startWelcome } from "/static/apps/welcome.js"; import { showDiscover, createThread } from "/static/newchat.js"; // ---- one Eleven: the scripted flows and the live agent share a thread ------- // sendScriptedBubble posts a scripted line as an ORDINARY chat message // authored "Eleven" — uid-less, so every client (native included) renders it // as the bot's bubble, and the sidecar's agent-name rule keeps the real // Eleven from replying to its own scripted half. `key` rides inside the // payload for resume dedup (scriptedSaid). Returns whether the frame sent. export async function sendScriptedBubble(threadId, text, key) { const tkey = threadKeys[threadId]; if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false; const blob = await encrypt(tkey, { name: "Eleven", text, ts: Date.now(), key }); ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); return true; } // sendTypedPayload posts a typed (non-chat) protocol payload into a thread — // the flow.state mute handshake rides this. export async function sendTypedPayload(threadId, payload) { const tkey = threadKeys[threadId]; if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false; const blob = await encrypt(tkey, { ...payload, ts: Date.now() }); ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); return true; } // scriptedSaid: is a scripted line (by dedup key) already in the thread's // transcript? Reads the chat app's folded state, which the open feed keeps. export function scriptedSaid(threadId, key) { const st = appStateFor(threadId, "chat"); return !!(st && st.msgs && st.msgs.some((m) => m.raw && m.raw.key === key && m.name === "Eleven")); } // liveElevenAgent: the roster's real agent Eleven, if one exists (with a key, // so a DM can be wrapped to it). The signal that flows should run in ITS DM. export function liveElevenAgent() { return roster.find((e) => e.is_agent && e.public_key && /^eleven$/i.test(e.label || "")) || null; } export async function maybeStartWelcome() { const pending = verbs.welcomePending(); if (!pending) return; // the marker is set only by a SUCCESSFUL member-invite // claim (join.js clears it on failure), and cleared at stepFinish — so a stale // one can't hijack an add-device sign-in (that path never sets it). try { // One Eleven: with a real agent Eleven on the roster, the welcome runs in // ITS DM — the scripted floor and the AI ceiling in one thread. The // app-thread fallback below stays for instances with no sidecar (a fresh // space must onboard with no bot and no key — docs/conversation-scripts.md). const agent = liveElevenAgent(); let live = null; let id; if (agent) { id = await createThread([agent.user_id], { group: false }); // dedupes to the existing DM if (!id) return; await verbs.loadThreads(); await verbs.seedAppState(id); // welcome.step resume state (typed events) live = { said: (key) => scriptedSaid(id, key), post: (text, key) => sendScriptedBubble(id, text, key), // The mute handshake: the sidecar brain holds its tongue while the // script has the floor (runtime.go handleTyped "flow.state"). flowState: (active) => sendTypedPayload(id, { type: "flow.state", active: !!active }), }; await verbs.openThreadById(id); } else { id = await verbs.resolveAppThread("welcome"); if (!id) return; await verbs.openThread(id); } const pinFeed = () => requestAnimationFrame(() => { const feed = document.querySelector(".chatapp-feed"); if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide }); // The greeting names who runs the space AND who invited them — both are // encrypted labels, so they need the instance key: a fresh joiner's key // grant usually lands within a couple of seconds of the boot-time nudge. // Wait briefly rather than open with "one of its members" when we're // about to know better. (Everything label-derived reads AFTER this.) for (let i = 0; i < 12 && !instanceKey; i++) await new Promise((r) => setTimeout(r, 250)); if (instanceKey) await verbs.decryptRosterLabels(); // The DM the invite created (captured before renderApp consumed its // marker): its other member is the inviter, for the greeting and the // "Say hi" chip. let inviterName = ""; let openPendingThread = null; // The captured marker survives one load only — on a mid-welcome reload, // fall back to the joiner's (sole) person-DM, which is the same thread. const isAgentUid = (uid) => { const e = roster.find((x) => x.user_id === uid); return !!(e && e.is_agent); }; const dm = (pendingJoinThread && threads.find((t) => t.id === pendingJoinThread)) || threads.find( (t) => t.kind === "dm" && !threadApp[t.id] && // The inviter is a PERSON — the live Eleven DM (one-Eleven mode) // must never be mistaken for the invite's pending thread. (t.members || []).some((uid) => uid !== myUserId && !isAgentUid(uid)), ); if (dm) { const other = (dm.members || []).find((uid) => uid !== myUserId); const entry = other && roster.find((x) => x.user_id === other); inviterName = (entry && entry.label) || ""; const dmId = dm.id; openPendingThread = async () => { await verbs.openThreadById(dmId); // They tapped "Say hi" — put the cursor where the hi goes. requestAnimationFrame(() => { const ta = document.querySelector(".chatapp-root .composer .composer-input"); if (ta) ta.focus(); }); }; } const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin"); // Open groups: Eleven's finish mentions the directory when there's // something this member could actually join — existence only, never names // (a locked level's name stays sealed by design). Joined rows don't count: // default-join groups are already in the sidebar. let openGroups = false; try { const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json(); openGroups = (r.threads || []).some((t) => !t.joined && (!t.open_tier || t.eligible)); } catch {} startWelcome({ threadId: id, live, suggested: (pending.suggested || "").slice(0, 32), inviterName, adminName: (adminEntry && adminEntry.label) || "", openPendingThread, openGroups, openDiscover: () => showDiscover(), openQuickForm: () => openWelcomeQuickForm(id, (pending.suggested || "").slice(0, 32)), typing: (on) => { setNoticeTyping(id, "Eleven", on); if (on) pinFeed(); }, respond: (spec) => appendNotice(id, spec), }); } 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 = verbs.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. export 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 bots = quickFormToggle(extras, { id: "qf-bots", label: "Bots", desc: "Everything essential works with these off — change your mind anytime in Admin.", }); const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" }); // AI is opt-IN — unticked by default, with the honest note (mirrors the // conversation's dedicated ai-optin step; docs/ai-provisioning.md). const ai = quickFormToggle(extras, { id: "qf-ai", label: "AI — bring Eleven in", desc: "Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in.", checked: false, }); 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.