| 1 | // One Eleven + the quick forms: the scripted setup/welcome conversations and |
| 2 | // their "Fill in a form instead" fast path. The scripted flows and the live |
| 3 | // agent share a thread — this module bridges the flow engine (setup.flow.js / |
| 4 | // welcome.flow.js) into the chat app and turns a submitted form into the same |
| 5 | // recorded steps the conversation would have produced. Extracted verbatim |
| 6 | // from shell.js; behaviours ride the verbs seam. |
| 7 | import { verbs } from "/static/shellverbs.js"; |
| 8 | import { session, roster, myUserId, threads, threadKeys, activeId, threadName, embed, ws, instanceKey, threadApp, pendingJoinThread } from "/static/shellstate.js"; |
| 9 | import { encrypt } from "/static/crypto.js"; |
| 10 | import { appStateFor, setupBridge, spaceInfo, emitAppEvent } from "/static/apps.js"; |
| 11 | import { appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js"; |
| 12 | import { planQuickForm } from "/static/quickform.js"; |
| 13 | import { startWelcome } from "/static/apps/welcome.js"; |
| 14 | import { showDiscover, createThread } from "/static/newchat.js"; |
| 15 | |
| 16 | // ---- one Eleven: the scripted flows and the live agent share a thread ------- |
| 17 | |
| 18 | // sendScriptedBubble posts a scripted line as an ORDINARY chat message |
| 19 | // authored "Eleven" — uid-less, so every client (native included) renders it |
| 20 | // as the bot's bubble, and the sidecar's agent-name rule keeps the real |
| 21 | // Eleven from replying to its own scripted half. `key` rides inside the |
| 22 | // payload for resume dedup (scriptedSaid). Returns whether the frame sent. |
| 23 | export async function sendScriptedBubble(threadId, text, key) { |
| 24 | const tkey = threadKeys[threadId]; |
| 25 | if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| 26 | const blob = await encrypt(tkey, { name: "Eleven", text, ts: Date.now(), key }); |
| 27 | ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| 28 | return true; |
| 29 | } |
| 30 | |
| 31 | // sendTypedPayload posts a typed (non-chat) protocol payload into a thread — |
| 32 | // the flow.state mute handshake rides this. |
| 33 | export async function sendTypedPayload(threadId, payload) { |
| 34 | const tkey = threadKeys[threadId]; |
| 35 | if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| 36 | const blob = await encrypt(tkey, { ...payload, ts: Date.now() }); |
| 37 | ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| 38 | return true; |
| 39 | } |
| 40 | |
| 41 | // scriptedSaid: is a scripted line (by dedup key) already in the thread's |
| 42 | // transcript? Reads the chat app's folded state, which the open feed keeps. |
| 43 | export function scriptedSaid(threadId, key) { |
| 44 | const st = appStateFor(threadId, "chat"); |
| 45 | return !!(st && st.msgs && st.msgs.some((m) => m.raw && m.raw.key === key && m.name === "Eleven")); |
| 46 | } |
| 47 | |
| 48 | // liveElevenAgent: the roster's real agent Eleven, if one exists (with a key, |
| 49 | // so a DM can be wrapped to it). The signal that flows should run in ITS DM. |
| 50 | export function liveElevenAgent() { |
| 51 | return roster.find((e) => e.is_agent && e.public_key && /^eleven$/i.test(e.label || "")) || null; |
| 52 | } |
| 53 | |
| 54 | export async function maybeStartWelcome() { |
| 55 | const pending = verbs.welcomePending(); |
| 56 | if (!pending) return; // the marker is set only by a SUCCESSFUL member-invite |
| 57 | // claim (join.js clears it on failure), and cleared at stepFinish — so a stale |
| 58 | // one can't hijack an add-device sign-in (that path never sets it). |
| 59 | try { |
| 60 | // One Eleven: with a real agent Eleven on the roster, the welcome runs in |
| 61 | // ITS DM — the scripted floor and the AI ceiling in one thread. The |
| 62 | // app-thread fallback below stays for instances with no sidecar (a fresh |
| 63 | // space must onboard with no bot and no key — docs/conversation-scripts.md). |
| 64 | const agent = liveElevenAgent(); |
| 65 | let live = null; |
| 66 | let id; |
| 67 | if (agent) { |
| 68 | id = await createThread([agent.user_id], { group: false }); // dedupes to the existing DM |
| 69 | if (!id) return; |
| 70 | await verbs.loadThreads(); |
| 71 | await verbs.seedAppState(id); // welcome.step resume state (typed events) |
| 72 | live = { |
| 73 | said: (key) => scriptedSaid(id, key), |
| 74 | post: (text, key) => sendScriptedBubble(id, text, key), |
| 75 | // The mute handshake: the sidecar brain holds its tongue while the |
| 76 | // script has the floor (runtime.go handleTyped "flow.state"). |
| 77 | flowState: (active) => sendTypedPayload(id, { type: "flow.state", active: !!active }), |
| 78 | }; |
| 79 | await verbs.openThreadById(id); |
| 80 | } else { |
| 81 | id = await verbs.resolveAppThread("welcome"); |
| 82 | if (!id) return; |
| 83 | await verbs.openThread(id); |
| 84 | } |
| 85 | const pinFeed = () => |
| 86 | requestAnimationFrame(() => { |
| 87 | const feed = document.querySelector(".chatapp-feed"); |
| 88 | if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide |
| 89 | }); |
| 90 | // The greeting names who runs the space AND who invited them — both are |
| 91 | // encrypted labels, so they need the instance key: a fresh joiner's key |
| 92 | // grant usually lands within a couple of seconds of the boot-time nudge. |
| 93 | // Wait briefly rather than open with "one of its members" when we're |
| 94 | // about to know better. (Everything label-derived reads AFTER this.) |
| 95 | for (let i = 0; i < 12 && !instanceKey; i++) await new Promise((r) => setTimeout(r, 250)); |
| 96 | if (instanceKey) await verbs.decryptRosterLabels(); |
| 97 | // The DM the invite created (captured before renderApp consumed its |
| 98 | // marker): its other member is the inviter, for the greeting and the |
| 99 | // "Say hi" chip. |
| 100 | let inviterName = ""; |
| 101 | let openPendingThread = null; |
| 102 | // The captured marker survives one load only — on a mid-welcome reload, |
| 103 | // fall back to the joiner's (sole) person-DM, which is the same thread. |
| 104 | const isAgentUid = (uid) => { |
| 105 | const e = roster.find((x) => x.user_id === uid); |
| 106 | return !!(e && e.is_agent); |
| 107 | }; |
| 108 | const dm = |
| 109 | (pendingJoinThread && threads.find((t) => t.id === pendingJoinThread)) || |
| 110 | threads.find( |
| 111 | (t) => |
| 112 | t.kind === "dm" && |
| 113 | !threadApp[t.id] && |
| 114 | // The inviter is a PERSON — the live Eleven DM (one-Eleven mode) |
| 115 | // must never be mistaken for the invite's pending thread. |
| 116 | (t.members || []).some((uid) => uid !== myUserId && !isAgentUid(uid)), |
| 117 | ); |
| 118 | if (dm) { |
| 119 | const other = (dm.members || []).find((uid) => uid !== myUserId); |
| 120 | const entry = other && roster.find((x) => x.user_id === other); |
| 121 | inviterName = (entry && entry.label) || ""; |
| 122 | const dmId = dm.id; |
| 123 | openPendingThread = async () => { |
| 124 | await verbs.openThreadById(dmId); |
| 125 | // They tapped "Say hi" — put the cursor where the hi goes. |
| 126 | requestAnimationFrame(() => { |
| 127 | const ta = document.querySelector(".chatapp-root .composer .composer-input"); |
| 128 | if (ta) ta.focus(); |
| 129 | }); |
| 130 | }; |
| 131 | } |
| 132 | const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin"); |
| 133 | // Open groups: Eleven's finish mentions the directory when there's |
| 134 | // something this member could actually join — existence only, never names |
| 135 | // (a locked level's name stays sealed by design). Joined rows don't count: |
| 136 | // default-join groups are already in the sidebar. |
| 137 | let openGroups = false; |
| 138 | try { |
| 139 | const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json(); |
| 140 | openGroups = (r.threads || []).some((t) => !t.joined && (!t.open_tier || t.eligible)); |
| 141 | } catch {} |
| 142 | startWelcome({ |
| 143 | threadId: id, |
| 144 | live, |
| 145 | suggested: (pending.suggested || "").slice(0, 32), |
| 146 | inviterName, |
| 147 | adminName: (adminEntry && adminEntry.label) || "", |
| 148 | openPendingThread, |
| 149 | openGroups, |
| 150 | openDiscover: () => showDiscover(), |
| 151 | openQuickForm: () => openWelcomeQuickForm(id, (pending.suggested || "").slice(0, 32)), |
| 152 | typing: (on) => { |
| 153 | setNoticeTyping(id, "Eleven", on); |
| 154 | if (on) pinFeed(); |
| 155 | }, |
| 156 | respond: (spec) => appendNotice(id, spec), |
| 157 | }); |
| 158 | } catch {} |
| 159 | } |
| 160 | |
| 161 | // ---- the quick forms ("Fill in a form instead") ------------------------------ |
| 162 | // The onboarding conversations' fast path: one modal form with every question |
| 163 | // at once, ending in the same Sign in. Submitting FAST-FILLS the flow's own |
| 164 | // records — the same bridge verbs and the same step records (labels verbatim — |
| 165 | // planQuickForm) the conversational path would produce — so the flow's resume |
| 166 | // machinery does the rest: after the sign-in ceremony the run re-enters, skips |
| 167 | // the answered steps, and settles through the access/keys step like any other |
| 168 | // return leg. Escape/Cancel closes without touching the conversation. |
| 169 | |
| 170 | // One labelled text field (the shared .eleven-input surface). |
| 171 | function quickFormField(parent, { id, label, value = "", hint = "", maxLength, type = "text" }) { |
| 172 | const lab = document.createElement("label"); |
| 173 | lab.className = "qf-label"; |
| 174 | lab.htmlFor = id; |
| 175 | lab.textContent = label; |
| 176 | const input = document.createElement("input"); |
| 177 | input.id = id; |
| 178 | input.type = type; |
| 179 | input.className = "eleven-input sheet-input"; |
| 180 | input.value = value; |
| 181 | if (hint) input.placeholder = hint; |
| 182 | if (maxLength) input.maxLength = maxLength; |
| 183 | parent.append(lab, input); |
| 184 | return input; |
| 185 | } |
| 186 | |
| 187 | // One toggle row (the shared .opt-row recipe: the what, then the so-what). |
| 188 | function quickFormToggle(parent, { id, label, desc = "", checked = true }) { |
| 189 | const row = document.createElement("label"); |
| 190 | row.className = "opt-row"; |
| 191 | const cb = document.createElement("input"); |
| 192 | cb.type = "checkbox"; |
| 193 | cb.id = id; |
| 194 | cb.checked = checked; |
| 195 | const txt = document.createElement("span"); |
| 196 | txt.textContent = label; |
| 197 | if (desc) { |
| 198 | const d = document.createElement("span"); |
| 199 | d.className = "opt-desc"; |
| 200 | d.textContent = desc; |
| 201 | txt.appendChild(d); |
| 202 | } |
| 203 | row.append(cb, txt); |
| 204 | parent.appendChild(row); |
| 205 | return cb; |
| 206 | } |
| 207 | |
| 208 | // The shared sheet skeleton: title, caller-filled body, an inline error line |
| 209 | // (inline, occupying layout — never a floating toast), then Sign in / Cancel. |
| 210 | function quickFormSheet(title) { |
| 211 | const backdrop = verbs.modal(); |
| 212 | const card = backdrop.querySelector(".sheet"); |
| 213 | const head = document.createElement("div"); |
| 214 | head.className = "sheet-head"; |
| 215 | const h = document.createElement("h2"); |
| 216 | h.textContent = title; |
| 217 | head.appendChild(h); |
| 218 | card.appendChild(head); |
| 219 | const error = document.createElement("p"); |
| 220 | error.className = "qf-error"; |
| 221 | error.setAttribute("role", "alert"); // announced when the text lands |
| 222 | error.hidden = true; |
| 223 | const row = document.createElement("div"); |
| 224 | row.className = "row"; |
| 225 | const submit = document.createElement("button"); |
| 226 | submit.textContent = "Sign in"; |
| 227 | const cancel = document.createElement("button"); |
| 228 | cancel.className = "secondary"; |
| 229 | cancel.textContent = "Cancel"; |
| 230 | cancel.onclick = () => backdrop.remove(); // the conversation stays untouched |
| 231 | row.append(submit, cancel); |
| 232 | return { |
| 233 | backdrop, |
| 234 | card, |
| 235 | submit, |
| 236 | // Appended by the caller once its fields are in, keeping field → error → |
| 237 | // buttons in reading (and tab) order. |
| 238 | finish: () => card.append(error, row), |
| 239 | setError: (msg) => { |
| 240 | error.textContent = msg; |
| 241 | error.hidden = !msg; |
| 242 | }, |
| 243 | }; |
| 244 | } |
| 245 | |
| 246 | // Run the plan: verbs through the SAME setup bridge the conversation uses |
| 247 | // (only the flow's own `on`-branched verbs — group name, email — can push |
| 248 | // back), then the step records, then the sign-in hand-off. Returns null on |
| 249 | // success or the inline error message to show. |
| 250 | async function submitQuickForm(kind, threadId, values, backdrop) { |
| 251 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 252 | const plan = planQuickForm(kind, values); |
| 253 | const bridge = setupBridge(); |
| 254 | for (const v of plan.verbs) { |
| 255 | let r; |
| 256 | try { |
| 257 | r = await bridge[v.verb](...v.args); |
| 258 | } catch { |
| 259 | r = "error"; |
| 260 | } |
| 261 | // The two verbs the conversation branches on push back here the same way |
| 262 | // (setup.flow.js `on.invalid`); the rest are fire-and-forget, as in the flow. |
| 263 | if (v.verb === "setGroupName" && r !== "ok") { |
| 264 | return r === "invalid" |
| 265 | ? "That space name didn't stick — it needs to be 1–60 characters." |
| 266 | : "Something went wrong saving the space name — try again in a moment."; |
| 267 | } |
| 268 | if (v.verb === "setContactEmail" && r !== "ok") { |
| 269 | return r === "invalid" |
| 270 | ? "That doesn't look like an email address — fix it, or leave it empty." |
| 271 | : "Something went wrong saving the email — try again in a moment."; |
| 272 | } |
| 273 | } |
| 274 | // The records, through the same emit path as io.record — skipping steps the |
| 275 | // conversation already recorded (the flow never re-records a done step). |
| 276 | const type = kind + ".step"; |
| 277 | const stepDone = (step) => { |
| 278 | const st = appStateFor(threadId, kind); |
| 279 | return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| 280 | }; |
| 281 | const records = plan.records.filter((r) => !stepDone(r.step)); |
| 282 | for (const r of records) { |
| 283 | // Same send-retry discipline as makeSay: the socket may still be connecting. |
| 284 | let sent = false; |
| 285 | for (let i = 0; i < 12 && !sent; i++) { |
| 286 | sent = await emitAppEvent(threadId, { type, step: r.step, label: r.label, value: r.value }); |
| 287 | if (!sent) await sleep(150); |
| 288 | } |
| 289 | if (!sent) return "Couldn't save that — check your connection and try again."; |
| 290 | } |
| 291 | // Records travel over the WebSocket: wait for the echoes to fold into the |
| 292 | // app state BEFORE the sign-in navigation, or a same-tab redirect can outrun |
| 293 | // them and lose the fast-fill (capped — a lost echo mustn't wedge the form). |
| 294 | for (let i = 0; i < 20 && !records.every((r) => stepDone(r.step)); i++) await sleep(100); |
| 295 | backdrop.remove(); |
| 296 | // Stay-put contexts (the native wrapper, an installed PWA) run the ceremony |
| 297 | // elsewhere and keep this page alive — reload it shortly after: the live |
| 298 | // flow engine is parked mid-step (its armed ask and settle belong to the |
| 299 | // step the form just answered), so a resume over the fast-filled records is |
| 300 | // the one honest re-entry. The reloaded flow skips the answered steps and |
| 301 | // sits on the sign-in step, whose settle poll (plus the enrolment completion |
| 302 | // poller) finishes the job when the ceremony completes. Same-tab contexts |
| 303 | // navigate away inside savePasskey — the pagehide guard keeps this timer |
| 304 | // from firing under them. |
| 305 | let left = false; |
| 306 | addEventListener("pagehide", () => (left = true), { once: true }); |
| 307 | setTimeout(() => { |
| 308 | if (!left && document.visibilityState === "visible") location.reload(); |
| 309 | }, 1200); |
| 310 | bridge.savePasskey(); |
| 311 | return null; |
| 312 | } |
| 313 | |
| 314 | // The joiner's quick form: just the name, then Sign in. |
| 315 | function openWelcomeQuickForm(threadId, suggested) { |
| 316 | const sheet = quickFormSheet("Introduce yourself"); |
| 317 | const name = quickFormField(sheet.card, { |
| 318 | id: "qf-name", |
| 319 | label: "Your name", |
| 320 | value: suggested || "", |
| 321 | hint: "e.g. Finn Wolfhard", |
| 322 | maxLength: 32, |
| 323 | }); |
| 324 | const note = document.createElement("p"); |
| 325 | note.className = "sub qf-note"; |
| 326 | note.textContent = "You'll be brought straight back here."; |
| 327 | sheet.card.appendChild(note); |
| 328 | sheet.finish(); |
| 329 | sheet.submit.onclick = async () => { |
| 330 | if (!name.value.trim()) { |
| 331 | sheet.setError("Add your name to continue."); |
| 332 | name.focus(); |
| 333 | return; |
| 334 | } |
| 335 | sheet.submit.disabled = true; |
| 336 | const err = await submitQuickForm("welcome", threadId, { name: name.value }, sheet.backdrop); |
| 337 | if (err) { |
| 338 | sheet.setError(err); |
| 339 | sheet.submit.disabled = false; |
| 340 | } |
| 341 | }; |
| 342 | } |
| 343 | |
| 344 | // The admin's quick form: the whole setup at once, then Sign in. |
| 345 | export async function openSetupQuickForm(threadId) { |
| 346 | // The current space name first (the flow's "Keep" affordance becomes a prefill). |
| 347 | const space = await spaceInfo(); |
| 348 | const sheet = quickFormSheet("Set up your space"); |
| 349 | const name = quickFormField(sheet.card, { |
| 350 | id: "qf-name", |
| 351 | label: "Your name", |
| 352 | value: session.name || "", |
| 353 | hint: "e.g. Finn Wolfhard", |
| 354 | maxLength: 32, |
| 355 | }); |
| 356 | const spaceName = quickFormField(sheet.card, { |
| 357 | id: "qf-space", |
| 358 | label: "Space name", |
| 359 | value: space && space.nameSet ? space.name || "" : "", |
| 360 | hint: "e.g. The Book Club", |
| 361 | maxLength: 60, |
| 362 | }); |
| 363 | const email = quickFormField(sheet.card, { |
| 364 | id: "qf-email", |
| 365 | label: "Contact email (optional)", |
| 366 | hint: "e.g. finn@example.com", |
| 367 | type: "email", |
| 368 | }); |
| 369 | const invites = quickFormToggle(sheet.card, { |
| 370 | id: "qf-invites", |
| 371 | label: "Members can invite people", |
| 372 | desc: "Otherwise, new faces only arrive through you.", |
| 373 | }); |
| 374 | const extras = document.createElement("div"); |
| 375 | extras.setAttribute("role", "group"); |
| 376 | extras.setAttribute("aria-labelledby", "qf-extras-label"); |
| 377 | const gh = document.createElement("p"); |
| 378 | gh.id = "qf-extras-label"; |
| 379 | gh.className = "qf-label"; |
| 380 | gh.textContent = "Extras"; |
| 381 | extras.appendChild(gh); |
| 382 | const bots = quickFormToggle(extras, { |
| 383 | id: "qf-bots", |
| 384 | label: "Bots", |
| 385 | desc: "Everything essential works with these off — change your mind anytime in Admin.", |
| 386 | }); |
| 387 | const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" }); |
| 388 | // AI is opt-IN — unticked by default, with the honest note (mirrors the |
| 389 | // conversation's dedicated ai-optin step; docs/ai-provisioning.md). |
| 390 | const ai = quickFormToggle(extras, { |
| 391 | id: "qf-ai", |
| 392 | label: "AI — bring Eleven in", |
| 393 | desc: |
| 394 | "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.", |
| 395 | checked: false, |
| 396 | }); |
| 397 | sheet.card.appendChild(extras); |
| 398 | sheet.finish(); |
| 399 | sheet.submit.onclick = async () => { |
| 400 | if (!name.value.trim()) { |
| 401 | sheet.setError("Add your name to continue."); |
| 402 | name.focus(); |
| 403 | return; |
| 404 | } |
| 405 | if (!spaceName.value.trim()) { |
| 406 | sheet.setError("Give your space a name to continue."); |
| 407 | spaceName.focus(); |
| 408 | return; |
| 409 | } |
| 410 | sheet.submit.disabled = true; |
| 411 | const err = await submitQuickForm( |
| 412 | "setup", |
| 413 | threadId, |
| 414 | { |
| 415 | name: name.value, |
| 416 | spaceName: spaceName.value, |
| 417 | email: email.value, |
| 418 | invites: invites.checked, |
| 419 | ai: ai.checked, |
| 420 | bots: bots.checked, |
| 421 | apps: apps.checked, |
| 422 | }, |
| 423 | sheet.backdrop, |
| 424 | ); |
| 425 | if (err) { |
| 426 | sheet.setError(err); |
| 427 | sheet.submit.disabled = false; |
| 428 | } |
| 429 | }; |
| 430 | } |
| 431 | |
| 432 | // Send a normal chat message (name + text) into a thread, as if typed. Same |
| 433 | // envelope + encryption as the composer and the app-event transport; used by the |
| 434 | // tour to ask Eleven a question on the member's behalf. |
| 435 | |