// First-run setup — Eleven walks a fresh instance's admin through naming // themselves and the space, contact email, server photo, member invites, and — // most importantly — backing up their key. Chat-first: the whole thing is a // conversation (chips + typed answers), not a console. See // docs/conversational-onboarding.md. // // The conversation itself is DATA: setup.flow.js, interpreted by the flow // engine (flow.js — docs/conversation-scripts.md). This file is the app shell // + the host binding: the bridge-verb table (the closed set of side effects // the flow may name) and the io wiring the engine to eleven.js's feel (pacing, // persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it // must work with no AI and no bot at all (a fresh instance has neither). import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js"; import { runFlow } from "../flow.js"; import SETUP_FLOW from "../setup.flow.js"; import { startTour } from "../tutorial.js"; // One declaration of the app's prefix — N.ev("step") → the "setup.step" wire type. const N = namespace("setup"); export const app = defineApp({ name: "setup", title: "Eleven", glyph: "🕚", avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph // The sidebar badges this thread BOT, not APP — it presents as a conversation // with Eleven, and the app machinery underneath is an implementation detail. senderKind: "bot", chatIntro: "This is the start of your chat with Eleven.", // Chat-only: the conversation IS the thread — no Interface view, no toggle. // (The old setup progress card was superseded by the Admin panel.) chatOnly: true, // System scope: never offered in the Apps menu — the host mounts it for the // admin on first run (maybeStartSetup in shell.js), and only for them. places: [], // host-mounted conversation initialState: () => ({ steps: {}, said: {} }), events: { // A completed step. `label` is the human line the chat view shows; `value` // (optional) remembers the answer, for steps that resume on it. [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), // Eleven's own lines persist too (via makeSay), so the conversation is // real history after a reload, not just the ✓ action lines. ...sayEvents(N.ev("say")), }, chatMessage(e) { const line = sayBubble(e, N.ev("say")); if (line) return line; if (e.type !== N.ev("step") || !e.label) return null; return { text: e.label, action: true }; }, // A step-record from ANOTHER client (a second device answering this same // conversation) landing while OUR engine sits parked on a question — the // 2026-07-19 stale-composer bug: the transcript streamed on live but the // armed "Your name:" chip never moved. Re-derive: tear the parked run down // and boot the flow again — every say/record dedups against the folded // log, so the restart lands parked on the first genuinely-unanswered step. // Own records are filtered by the ownRecords ledger (their socket echo is // not news); env.reentry suppresses the "Welcome back" re-greeting. folded(threadId, event) { if (event.type !== N.ev("step") || !event.step) return; const env = runningEnvs.get(threadId); if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended const own = ownRecords.get(threadId); if (own && own.has(event.step)) return; cancelAnswer(threadId); running.delete(threadId); clearRunTimers(threadId); // the old run's settle/probe timers die with it env.reentry = true; startSetup(env); }, }); // ---- the host binding ------------------------------------------------------- // One live run per thread (a reload starts a fresh one; the engine's step // guards and say-dedupe handle everything within). const running = new Set(); // The live run's env, kept so the folded hook can reboot the flow in place — // and the steps THIS client recorded, so its own echoes never trigger one. const runningEnvs = new Map(); // threadId → env const ownRecords = new Map(); // threadId → Set(step) const say = makeSay("setup", N.ev("say")); // ---- run-scoped timers (#1377) ---------------------------------------------- // flow.js documents io.delay as cancellable; a bare setTimeout let a torn-down // run's settle/probe timers keep walking the old step graph beside the new // run — two engines on one thread. Every timer is ledgered per thread and // cleared on re-derive, on end, and at pagehide (the same-tab sign-in // navigation: an in-flight probe was persisting an orphan "I'll wait here…"). const runTimers = new Map(); // threadId → Set(timeout id) export function delayFor(threadId) { return (ms, fn) => { let set = runTimers.get(threadId); if (!set) runTimers.set(threadId, (set = new Set())); const id = setTimeout(() => { set.delete(id); fn(); }, ms); set.add(id); }; } export function clearRunTimers(threadId) { const set = runTimers.get(threadId); if (!set) return; for (const id of set) clearTimeout(id); runTimers.delete(threadId); } // Guarded for Node (the flow tests import this module without a DOM). if (typeof addEventListener === "function") { addEventListener("pagehide", () => { for (const threadId of [...runTimers.keys()]) clearRunTimers(threadId); }); } // waitForRecordEchoes: records ride the WebSocket, and a same-tab redirect can // outrun their echoes and lose the step answered right before "Sign in" — the // quick form already waits exactly like this (onboardingui.js). Capped, so a // lost echo can never wedge the ceremony. export async function waitForRecordEchoes(threadId, app, own) { if (!own || !own.size) return; const settled = (step) => { const st = appStateFor(threadId, app); return !!(st && st.steps && st.steps[step] && st.steps[step].done); }; for (let i = 0; i < 20 && ![...own].every(settled); i++) await new Promise((r) => setTimeout(r, 100)); } // ---- the plan step's price data (#1374) ------------------------------------- // The price book, fetched from home's public /api/pricebook before the flow // runs (startSetup awaits it, so bridge.plan* are plain sync getters like // every other bridge field). null = the plan step doesn't happen: a native // wrapper (no external purchases — App Store rules), a self-hosted or // already-paid space, billing off, or an unreachable book. Keyed by AUDIENCE; // the book's tier keys are the ledger's routing keys, mapped here. let planBook = null; // { team|community|family: { key, name, price } } async function fetchPlanBook() { planBook = null; if (window.__entitlement !== "free" || !window.__homeOrigin) return; if (document.documentElement.classList.contains("native")) return; try { // Capped so a slow book never delays Eleven's hello — no book, no step. const r = await fetch(window.__homeOrigin + "/api/pricebook", { signal: AbortSignal.timeout(4000) }); if (!r.ok) return; const body = await r.json(); const audience = { business: "team", community: "community", family: "family" }; const book = {}; for (const t of body.tiers || []) { const kind = audience[t.key]; if (kind && t.name && t.label) book[kind] = { key: t.key, name: t.name, price: t.label }; } if (Object.keys(book).length) planBook = book; } catch { // Unreachable book — the conversation simply skips the plan step. } } // The bridge-verb table: the CLOSED set of side effects the setup flow may // name (docs/conversation-scripts.md § interpreter contract). A flow is data — // it can never do anything this table doesn't offer. function verb(name, args, env) { const bridge = setupBridge(); switch (name) { case "setMyName": return bridge.setMyName(args[0]); case "setGroupName": return bridge.setGroupName(args[0]); case "setContactEmail": return bridge.setContactEmail(args[0]); case "setUserInvites": return bridge.setUserInvites(args[0]); case "setFeature": // The extras steps' gates ("ai" | "bots" | "apps" → the #712 settings). return bridge.setFeature(args[0], args[1]); case "adoptServerPhoto": return bridge.adoptServerPhoto(env.threadId, args[0], args[1]); case "savePasskey": // Wait for this run's record echoes before the same-tab navigation — // the redirect must not outrun the step recorded just before the tap. return waitForRecordEchoes(env.threadId, "setup", ownRecords.get(env.threadId)).then(() => bridge.savePasskey()); case "markSetupComplete": return bridge.markSetupComplete(); case "deferBackupReminders": return bridge.deferBackupReminders && bridge.deferBackupReminders(); case "openAdmin": return openAppThread("admin"); case "openPlanCheckout": { // The finish line's payment chip (#1374): an upgrade of the already- // created (free) space on the home page — this server has no billing // surface. The recorded plan step holds the audience; the fetched book // maps it back to the tier key. Same-tab: setup is complete, and home // returns you to your list once the payment settles. const st = appStateFor(env.threadId, "setup"); const kind = st && st.steps && st.steps.plan && st.steps.plan.value; const tier = planBook && kind && planBook[kind]; if (!tier || !window.__homeOrigin) return; location.href = window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host) + "&tier=" + encodeURIComponent(tier.key); return; } case "openQuickForm": // The name step's fast path — the host owns the form sheet (web: // openSetupQuickForm in shell.js; native: the quick-form sheet). return env.openQuickForm && env.openQuickForm(); case "startTour": // Reached by tapping "Take the tour" at the end of setup — the user has // just spent minutes with Eleven, so skip the tour's own "Hi, fancy a // tour?" intro and drop straight into it. return startTour(env.respond, env.typing, { skipIntro: true }); default: throw new Error(`setup: unknown verb ${name}`); } } // startSetup(env): env = { threadId, respond, typing } — respond appends one // Eleven notice to the setup thread's feed. The host guarantees we're the // admin and setup isn't complete before calling. export function startSetup(env) { const bridge = setupBridge(); if (!bridge || running.has(env.threadId)) return; running.add(env.threadId); runningEnvs.set(env.threadId, env); env.sender = { name: "Eleven", kind: "bot" }; env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled(), // Did this admin arrive from their Eleven sign-in page (home)? If so the key // step offers to ADD this space to their existing passkey (keys-home) rather // than pitching passkeys from scratch. Must be forwarded here — the flow's // bridge.* only sees what env.bridge exposes. fromHome: () => setupBridge().fromHome(), // Whether signing in is possible at all — self-hosted spaces have no home // server, and the flow skips the un-actionable pitch (#1377). canEnrol: () => !!window.__homeOrigin, // The plan step's price book (#1374) — absent means the step is skipped. planPrices: () => !!planBook, planTeam: () => !!(planBook && planBook.team), planCommunity: () => !!(planBook && planBook.community), planFamily: () => !!(planBook && planBook.family), planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "", planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "", planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "", planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "", planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "", planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "", }; env.onEnd = () => { running.delete(env.threadId); runningEnvs.delete(env.threadId); ownRecords.delete(env.threadId); clearRunTimers(env.threadId); if (env.live) env.live.flowState(false); // un-mute the real Eleven }; if (env.live) env.live.flowState(true); // the script has the floor (one Eleven) const io = { say: env.live ? makeLiveSay(env.live) : say, ask: awaitAnswer, cancelAnswer, verb, record: (step, label, value) => { // Ledger our own record BEFORE the emit — its socket echo folds back // through the `folded` hook and must read as ours, not a foreign answer. let own = ownRecords.get(env.threadId); if (!own) ownRecords.set(env.threadId, (own = new Set())); own.add(step); return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }); }, done: (step) => { const st = appStateFor(env.threadId, "setup"); return !!(st && st.steps && st.steps[step] && st.steps[step].done); }, imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token), delay: delayFor(env.threadId), }; // The plan step's price book rides alongside the space info — both settle // before the flow starts, so every bridge getter stays synchronous. Promise.all([ spaceInfo() .then((info) => { env.space = info || {}; }) .catch(() => { env.space = {}; }), fetchPlanBook(), ]).finally(() => runFlow(SETUP_FLOW, env, io)); }