web: extract one Eleven + the quick forms into onboardingui.js (#972)
The scripted setup/welcome conversations and their form fast-path (419 lines, 6 exported entry points), verbatim through the verbs seam (four more shell verbs registered). The messaging primitives at the old span's tail (sendChatText, announce*, sendAndConfirm) stay in shell — they're the WS send path, not onboarding.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+438
−419
CLAUDE.md+2 −0web/static/onboardingui.js+434 −0web/static/shell.js+2 −419
diff --git a/CLAUDE.md b/CLAUDE.md| index d97634f..5cb6d31 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -203,6 +203,8 @@ Frontend module layout: |
| catalogue, and thread creation, through the verbs seam. |
| - `adddevice.js` — the add-a-device sheet (key-invite + QR; secret rides the |
| fragment). |
| +- `onboardingui.js` — one Eleven: the scripted setup/welcome conversations + |
| + the quick forms, bridging the flow engine into the chat app. |
| - `store.js` — the local message store (#457): IndexedDB cache of RAW server |
| responses (ciphertext rows + wrapped keys, never plaintext) that boot and |
| thread-opens hydrate from before reconciling over the network. Its pure |
diff --git a/web/static/onboardingui.js b/web/static/onboardingui.js| new file mode 100644 |
| index 0000000..a12e246 |
| --- /dev/null |
| +++ b/web/static/onboardingui.js |
| @@ -0,0 +1,434 @@ |
| +// 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 } 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/apps/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. |
diff --git a/web/static/shell.js b/web/static/shell.js| index f00c56c..6956fcd 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -139,6 +139,7 @@ import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresence |
| import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; |
| import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread } from "/static/newchat.js"; |
| import { addDevice } from "/static/adddevice.js"; |
| +import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js"; |
| import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md) |
| import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586) |
| import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md) |
| @@ -193,6 +194,7 @@ Object.assign(verbs, { |
| resolveRename, showList, paintHeaderAvatar, |
| loadTiers, setNickname, syncThreadKeysToSW, |
| inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed, |
| + decryptRosterLabels, openThreadById, seedAppState, welcomePending, |
| }); |
| onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change |
| @@ -5561,425 +5563,6 @@ function welcomePending() { |
| // A freshly-joined member's first load: open Eleven's welcome conversation |
| // before the reveal (the joiner mirror of maybeStartSetup). Runs until the |
| // conversation completes; each load resumes past the answered steps. |
| -// ---- 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. |
| -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. |
| -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. |
| -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. |
| -function liveElevenAgent() { |
| - return roster.find((e) => e.is_agent && e.public_key && /^eleven$/i.test(e.label || "")) || null; |
| -} |
| - |
| -async function maybeStartWelcome() { |
| - const pending = 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 loadThreads(); |
| - await 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 openThreadById(id); |
| - } else { |
| - id = await resolveAppThread("welcome"); |
| - if (!id) return; |
| - await 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 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 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 = 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 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. |
| async function sendChatText(threadId, text) { |
| const key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |