// Joiner onboarding — Eleven welcomes a NEW member the moment they arrive: // saving their access behind a passkey FIRST (so the round-trip back from the // ceremony lands mid-conversation with steps still to do, not on "all done"), // then name, profile photo, and an invitation to take the tour. Chat-first, // like the admin setup conversation (docs/conversational-onboarding.md §4): // claiming an invite lands straight in this conversation, no name form. // // The conversation itself is DATA: welcome.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 and the io wiring the engine to // eleven.js's feel and shell.js's setup bridge. Like setup it must work with // no AI and no sidecar (the sidecar's welcome DM stood down in its favour). import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js"; import { runFlow } from "../flow.js"; import WELCOME_FLOW from "../welcome.flow.js"; import { startTour } from "../tutorial.js"; // The run-scoped timer ledger + record-echo wait live with the setup host — // one implementation, both conversations (#1377). import { delayFor, clearRunTimers, waitForRecordEchoes } from "./setup.js"; // One declaration of the app's prefix — N.ev("step") → the "welcome.step" wire type. const N = namespace("welcome"); export const app = defineApp({ name: "welcome", title: "Eleven", glyph: "🕚", avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph // Presents as a conversation with Eleven (BOT in the sidebar, chat-only // surface) — the app machinery underneath is an implementation detail. senderKind: "bot", chatIntro: "This is the start of your chat with Eleven.", chatOnly: true, // System scope: never offered in the Apps menu — the host mounts it for a // freshly-joined member (maybeStartWelcome in shell.js), exactly once. places: [], // host-mounted conversation initialState: () => ({ steps: {}, said: {} }), events: { [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), ...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/tab answering this // same conversation) landing while OUR engine sits parked on a question — // the 2026-07-19 stale-composer bug, fixed for setup then and ported here // (#1377): 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. 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; startWelcome(env); }, }); // ---- the host binding ------------------------------------------------------- const running = new Set(); const runningEnvs = new Map(); // threadId → env, for the folded reboot const ownRecords = new Map(); // threadId → Set(step) — own echoes aren't news const say = makeSay("welcome", N.ev("say")); const stepState = (threadId, step) => { const st = appStateFor(threadId, "welcome"); return (st && st.steps && st.steps[step]) || null; }; // The bridge-verb table: the CLOSED set of side effects the welcome flow may // name (docs/conversation-scripts.md § interpreter contract). function makeVerb(env) { return function verb(name, args) { const bridge = setupBridge(); switch (name) { case "setMyName": // The same cap the profile rename applies — enforced here so the // suggested-name chip and the typed path can't diverge. return bridge.setMyName(String(args[0]).slice(0, 32)); case "adoptProfilePhoto": return bridge.adoptProfilePhoto(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, "welcome", ownRecords.get(env.threadId)).then(() => bridge.savePasskey()); case "deferBackupReminders": return bridge.deferBackupReminders && bridge.deferBackupReminders(); case "markWelcomeDone": return bridge.markWelcomeDone && bridge.markWelcomeDone(); case "openPendingThread": return env.openPendingThread && env.openPendingThread(); case "openDiscover": // "Browse open groups" on the finish step — the host supplies the // directory surface (web: showDiscover; native: the Discover sheet). return env.openDiscover && env.openDiscover(); case "openQuickForm": // The access step's fast path — the host owns the form sheet (web: // openWelcomeQuickForm in shell.js; native: the quick-form sheet). return env.openQuickForm && env.openQuickForm(); case "startTour": // Reached by "Take the tour" at the end of the welcome — the user has // just been talking to Eleven, so skip the tour's own "fancy a tour?" // greeting and go straight in. return startTour(env.respond, env.typing, { skipIntro: true }); default: throw new Error(`welcome: unknown verb ${name}`); } }; } // startWelcome(env): env = { threadId, respond, typing, suggested, inviterName, // adminName, openPendingThread } — the host supplies the notice sink, the // typing beat, the inviter's suggested name (from the invite fragment, never // the server), who invited them (decrypted roster label, "" if unknown), and a // callback opening the DM the invite created (null if none). // One-Eleven mode: when env.live is supplied (the roster has a real agent // Eleven), the flow runs INSIDE that agent's DM — scripted lines persist as // ordinary "Eleven" chat messages, and env.live.flowState mutes the sidecar // brain for the duration so the script and the model never talk over each // other. Without env.live (no sidecar — a fresh instance), the app-thread // fallback below is unchanged: onboarding never depends on a bot existing. export function startWelcome(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(), // Whether signing in is possible at all — self-hosted spaces have no home // server, and the flow skips the un-actionable ask (#1377). canEnrol: () => !!window.__homeOrigin, }; env.hasPendingThread = !!env.openPendingThread; // Backed-up is real state, not run-local memory: enrolled now, or the access // step recorded a passkey in an earlier session — or "native", where access // is secured by Sign in with Eleven and there's no browser key to back up. env.accessBackedUp = () => { const s = stepState(env.threadId, "access"); return setupBridge().enrolled() || !!(s && (s.value === "passkey" || s.value === "native")); }; 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 const io = { say: env.live ? makeLiveSay(env.live) : say, ask: awaitAnswer, cancelAnswer, verb: makeVerb(env), 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 s = stepState(env.threadId, step); return !!(s && s.done); }, imgSource: (token) => (token === "$myAvatar" ? setupBridge().myAvatarURL().catch(() => null) : token), delay: delayFor(env.threadId), }; spaceInfo() .then((info) => { env.space = info || {}; }) .catch(() => { env.space = {}; }) .finally(() => runFlow(WELCOME_FLOW, env, io)); }