eleven / messenger Public

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>
Paul Campbell 41dd04f6dbe56a5a9f70cb92446ba99700506b47 parent 1d4a91e
3 files changed, +438 −419
  • CLAUDE.md +2 −0
  • web/static/onboardingui.js +434 −0
  • web/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;