| 1 | // Joiner onboarding — Eleven welcomes a NEW member the moment they arrive: |
| 2 | // saving their access behind a passkey FIRST (so the round-trip back from the |
| 3 | // ceremony lands mid-conversation with steps still to do, not on "all done"), |
| 4 | // then name, profile photo, and an invitation to take the tour. Chat-first, |
| 5 | // like the admin setup conversation (docs/conversational-onboarding.md §4): |
| 6 | // claiming an invite lands straight in this conversation, no name form. |
| 7 | // |
| 8 | // The conversation itself is DATA: welcome.flow.js, interpreted by the flow |
| 9 | // engine (flow.js — docs/conversation-scripts.md). This file is the app shell |
| 10 | // + the host binding: the bridge-verb table and the io wiring the engine to |
| 11 | // eleven.js's feel and shell.js's setup bridge. Like setup it must work with |
| 12 | // no AI and no sidecar (the sidecar's welcome DM stood down in its favour). |
| 13 | import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; |
| 14 | import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js"; |
| 15 | import { runFlow } from "../flow.js"; |
| 16 | import WELCOME_FLOW from "../welcome.flow.js"; |
| 17 | import { startTour } from "../tutorial.js"; |
| 18 | // The run-scoped timer ledger + record-echo wait live with the setup host — |
| 19 | // one implementation, both conversations (#1377). |
| 20 | import { delayFor, clearRunTimers, waitForRecordEchoes } from "./setup.js"; |
| 21 | |
| 22 | // One declaration of the app's prefix — N.ev("step") → the "welcome.step" wire type. |
| 23 | const N = namespace("welcome"); |
| 24 | |
| 25 | export const app = defineApp({ |
| 26 | name: "welcome", |
| 27 | title: "Eleven", |
| 28 | glyph: "🕚", |
| 29 | avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph |
| 30 | // Presents as a conversation with Eleven (BOT in the sidebar, chat-only |
| 31 | // surface) — the app machinery underneath is an implementation detail. |
| 32 | senderKind: "bot", |
| 33 | chatIntro: "This is the start of your chat with Eleven.", |
| 34 | chatOnly: true, |
| 35 | // System scope: never offered in the Apps menu — the host mounts it for a |
| 36 | // freshly-joined member (maybeStartWelcome in shell.js), exactly once. |
| 37 | places: [], // host-mounted conversation |
| 38 | initialState: () => ({ steps: {}, said: {} }), |
| 39 | events: { |
| 40 | [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| 41 | ...sayEvents(N.ev("say")), |
| 42 | }, |
| 43 | chatMessage(e) { |
| 44 | const line = sayBubble(e, N.ev("say")); |
| 45 | if (line) return line; |
| 46 | if (e.type !== N.ev("step") || !e.label) return null; |
| 47 | return { text: e.label, action: true }; |
| 48 | }, |
| 49 | // A step-record from ANOTHER client (a second device/tab answering this |
| 50 | // same conversation) landing while OUR engine sits parked on a question — |
| 51 | // the 2026-07-19 stale-composer bug, fixed for setup then and ported here |
| 52 | // (#1377): tear the parked run down and boot the flow again; every |
| 53 | // say/record dedups against the folded log, so the restart lands parked on |
| 54 | // the first genuinely-unanswered step. |
| 55 | folded(threadId, event) { |
| 56 | if (event.type !== N.ev("step") || !event.step) return; |
| 57 | const env = runningEnvs.get(threadId); |
| 58 | if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended |
| 59 | const own = ownRecords.get(threadId); |
| 60 | if (own && own.has(event.step)) return; |
| 61 | cancelAnswer(threadId); |
| 62 | running.delete(threadId); |
| 63 | clearRunTimers(threadId); // the old run's settle/probe timers die with it |
| 64 | env.reentry = true; |
| 65 | startWelcome(env); |
| 66 | }, |
| 67 | }); |
| 68 | |
| 69 | // ---- the host binding ------------------------------------------------------- |
| 70 | |
| 71 | const running = new Set(); |
| 72 | const runningEnvs = new Map(); // threadId → env, for the folded reboot |
| 73 | const ownRecords = new Map(); // threadId → Set(step) — own echoes aren't news |
| 74 | const say = makeSay("welcome", N.ev("say")); |
| 75 | |
| 76 | const stepState = (threadId, step) => { |
| 77 | const st = appStateFor(threadId, "welcome"); |
| 78 | return (st && st.steps && st.steps[step]) || null; |
| 79 | }; |
| 80 | |
| 81 | // The bridge-verb table: the CLOSED set of side effects the welcome flow may |
| 82 | // name (docs/conversation-scripts.md § interpreter contract). |
| 83 | function makeVerb(env) { |
| 84 | return function verb(name, args) { |
| 85 | const bridge = setupBridge(); |
| 86 | switch (name) { |
| 87 | case "setMyName": |
| 88 | // The same cap the profile rename applies — enforced here so the |
| 89 | // suggested-name chip and the typed path can't diverge. |
| 90 | return bridge.setMyName(String(args[0]).slice(0, 32)); |
| 91 | case "adoptProfilePhoto": |
| 92 | return bridge.adoptProfilePhoto(env.threadId, args[0], args[1]); |
| 93 | case "savePasskey": |
| 94 | // Wait for this run's record echoes before the same-tab navigation — |
| 95 | // the redirect must not outrun the step recorded just before the tap. |
| 96 | return waitForRecordEchoes(env.threadId, "welcome", ownRecords.get(env.threadId)).then(() => bridge.savePasskey()); |
| 97 | case "deferBackupReminders": |
| 98 | return bridge.deferBackupReminders && bridge.deferBackupReminders(); |
| 99 | case "markWelcomeDone": |
| 100 | return bridge.markWelcomeDone && bridge.markWelcomeDone(); |
| 101 | case "openPendingThread": |
| 102 | return env.openPendingThread && env.openPendingThread(); |
| 103 | case "openDiscover": |
| 104 | // "Browse open groups" on the finish step — the host supplies the |
| 105 | // directory surface (web: showDiscover; native: the Discover sheet). |
| 106 | return env.openDiscover && env.openDiscover(); |
| 107 | case "openQuickForm": |
| 108 | // The access step's fast path — the host owns the form sheet (web: |
| 109 | // openWelcomeQuickForm in shell.js; native: the quick-form sheet). |
| 110 | return env.openQuickForm && env.openQuickForm(); |
| 111 | case "startTour": |
| 112 | // Reached by "Take the tour" at the end of the welcome — the user has |
| 113 | // just been talking to Eleven, so skip the tour's own "fancy a tour?" |
| 114 | // greeting and go straight in. |
| 115 | return startTour(env.respond, env.typing, { skipIntro: true }); |
| 116 | default: |
| 117 | throw new Error(`welcome: unknown verb ${name}`); |
| 118 | } |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | // startWelcome(env): env = { threadId, respond, typing, suggested, inviterName, |
| 123 | // adminName, openPendingThread } — the host supplies the notice sink, the |
| 124 | // typing beat, the inviter's suggested name (from the invite fragment, never |
| 125 | // the server), who invited them (decrypted roster label, "" if unknown), and a |
| 126 | // callback opening the DM the invite created (null if none). |
| 127 | // One-Eleven mode: when env.live is supplied (the roster has a real agent |
| 128 | // Eleven), the flow runs INSIDE that agent's DM — scripted lines persist as |
| 129 | // ordinary "Eleven" chat messages, and env.live.flowState mutes the sidecar |
| 130 | // brain for the duration so the script and the model never talk over each |
| 131 | // other. Without env.live (no sidecar — a fresh instance), the app-thread |
| 132 | // fallback below is unchanged: onboarding never depends on a bot existing. |
| 133 | export function startWelcome(env) { |
| 134 | const bridge = setupBridge(); |
| 135 | if (!bridge || running.has(env.threadId)) return; |
| 136 | running.add(env.threadId); |
| 137 | runningEnvs.set(env.threadId, env); |
| 138 | env.sender = { name: "Eleven", kind: "bot" }; |
| 139 | env.bridge = { |
| 140 | myName: () => setupBridge().myName(), |
| 141 | enrolled: () => setupBridge().enrolled(), |
| 142 | // Whether signing in is possible at all — self-hosted spaces have no home |
| 143 | // server, and the flow skips the un-actionable ask (#1377). |
| 144 | canEnrol: () => !!window.__homeOrigin, |
| 145 | }; |
| 146 | env.hasPendingThread = !!env.openPendingThread; |
| 147 | // Backed-up is real state, not run-local memory: enrolled now, or the access |
| 148 | // step recorded a passkey in an earlier session — or "native", where access |
| 149 | // is secured by Sign in with Eleven and there's no browser key to back up. |
| 150 | env.accessBackedUp = () => { |
| 151 | const s = stepState(env.threadId, "access"); |
| 152 | return setupBridge().enrolled() || !!(s && (s.value === "passkey" || s.value === "native")); |
| 153 | }; |
| 154 | env.onEnd = () => { |
| 155 | running.delete(env.threadId); |
| 156 | runningEnvs.delete(env.threadId); |
| 157 | ownRecords.delete(env.threadId); |
| 158 | clearRunTimers(env.threadId); |
| 159 | if (env.live) env.live.flowState(false); // un-mute the real Eleven |
| 160 | }; |
| 161 | if (env.live) env.live.flowState(true); // the script has the floor |
| 162 | const io = { |
| 163 | say: env.live ? makeLiveSay(env.live) : say, |
| 164 | ask: awaitAnswer, |
| 165 | cancelAnswer, |
| 166 | verb: makeVerb(env), |
| 167 | record: (step, label, value) => { |
| 168 | // Ledger our own record BEFORE the emit — its socket echo folds back |
| 169 | // through the `folded` hook and must read as ours, not a foreign answer. |
| 170 | let own = ownRecords.get(env.threadId); |
| 171 | if (!own) ownRecords.set(env.threadId, (own = new Set())); |
| 172 | own.add(step); |
| 173 | return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }); |
| 174 | }, |
| 175 | done: (step) => { |
| 176 | const s = stepState(env.threadId, step); |
| 177 | return !!(s && s.done); |
| 178 | }, |
| 179 | imgSource: (token) => (token === "$myAvatar" ? setupBridge().myAvatarURL().catch(() => null) : token), |
| 180 | delay: delayFor(env.threadId), |
| 181 | }; |
| 182 | spaceInfo() |
| 183 | .then((info) => { |
| 184 | env.space = info || {}; |
| 185 | }) |
| 186 | .catch(() => { |
| 187 | env.space = {}; |
| 188 | }) |
| 189 | .finally(() => runFlow(WELCOME_FLOW, env, io)); |
| 190 | } |
| 191 | |