Eleven keeps its head through the sign-in detour (top fixes from #1377) (#1380)
* server+web: plan choice moves into Eleven's conversation; create is never pay-gated (#1374) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: harden Eleven's conversations around the sign-in detour (#1377) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+271
−20
changelog/2026-07-28-eleven-signin-detour.md+6 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+26 −3ios/LChatCore/Sources/LChatCore/Flows/welcome.json+20 −2test/flow.test.mjs+64 −1web/static/apps/setup.js+53 −2web/static/apps/welcome.js+47 −5web/static/flow.js+6 −1web/static/setup.flow.js+23 −3web/static/shell.js+17 −1web/static/welcome.flow.js+9 −2
diff --git a/changelog/2026-07-28-eleven-signin-detour.md b/changelog/2026-07-28-eleven-signin-detour.md| new file mode 100644 |
| index 0000000..8716792 |
| --- /dev/null |
| +++ b/changelog/2026-07-28-eleven-signin-detour.md |
| @@ -0,0 +1,6 @@ |
| +π€ Eleven keeps its head through the sign-in detour. Coming back from |
| +signing in no longer risks the setup conversation repeating its whole |
| +key speech, losing your just-given answer, or not appearing at all on a |
| +slow connection β and finishing setup now always counts as finished. |
| +Second-device answers slot in cleanly for joiners too, and on self-hosted |
| +spaces Eleven no longer offers a sign-in that isn't there. |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 21591b2..190ae24 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -671,6 +671,18 @@ |
| "sayIfNew": "You're signed in β your space's key is safe. β ", |
| "goto": "finish" |
| }, |
| + { |
| + "if": "done", |
| + "goto": "finish" |
| + }, |
| + { |
| + "if": "!bridge.canEnrol", |
| + "recordIfNew": { |
| + "label": "This browser holds this space's key", |
| + "value": "local" |
| + }, |
| + "goto": "finish" |
| + }, |
| { |
| "if": "bridge.fromHome", |
| "goto": "keys-home" |
| @@ -857,14 +869,20 @@ |
| "That's it β {space.name|your space} is ready! π\n\n", |
| "Everything we just set up lives in your Admin panel β invite links, the space's name and photo, who can invite. Type `/admin` anywhere to open it; it's yours alone as this space's admin.", |
| { |
| - "if": "!vars.backedUp", |
| + "if": { |
| + "all": [ |
| + "!vars.backedUp", |
| + "bridge.canEnrol" |
| + ] |
| + }, |
| "text": "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it β for everyone. Sign in any time and it's safe." |
| }, |
| { |
| "if": "vars.planTier", |
| "text": "\n\nAnd the plan you picked β tap below whenever you're ready to set up the payment. Your space runs free until then." |
| } |
| - ] |
| + ], |
| + "key": "q-finish" |
| }, |
| { |
| "text": "Where would you like to start?" |
| @@ -872,7 +890,12 @@ |
| ], |
| "chips": [ |
| { |
| - "if": "!vars.backedUp", |
| + "if": { |
| + "all": [ |
| + "!vars.backedUp", |
| + "bridge.canEnrol" |
| + ] |
| + }, |
| "label": "Sign in", |
| "advancing": false, |
| "effect": { |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index 577b83a..33b2284 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -74,6 +74,14 @@ |
| }, |
| "sayIfNew": "Great β welcome back. π You're signed in, so your access is safe. Let's continue.", |
| "goto": "name" |
| + }, |
| + { |
| + "if": "!bridge.canEnrol", |
| + "recordIfNew": { |
| + "label": "Your access lives in this browser", |
| + "value": "local" |
| + }, |
| + "goto": "name" |
| } |
| ], |
| "settle": { |
| @@ -332,7 +340,12 @@ |
| "text": "\n\nThis space also has open groups anyone here can join β have a look around whenever you like." |
| }, |
| { |
| - "if": "!accessBackedUp", |
| + "if": { |
| + "all": [ |
| + "!accessBackedUp", |
| + "bridge.canEnrol" |
| + ] |
| + }, |
| "text": "\n\nOne more reminder before you go: you haven't signed in yet, so your access to this space lives only in this browser β if it's cleared or lost, there's no way back in. Sign in any time and you're safe." |
| } |
| ], |
| @@ -344,7 +357,12 @@ |
| ], |
| "chips": [ |
| { |
| - "if": "!accessBackedUp", |
| + "if": { |
| + "all": [ |
| + "!accessBackedUp", |
| + "bridge.canEnrol" |
| + ] |
| + }, |
| "label": "Sign in", |
| "advancing": false, |
| "effect": { |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 8eba4c5..1246a5f 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -13,7 +13,7 @@ const tick = () => new Promise((r) => setImmediate(r)); |
| // A fake io that mirrors the real engine surface: say dedupes by key||text |
| // (like eleven.js) but always re-arms chips; verbs return scripted results. |
| -function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", planBook = null, envExtra = {} } = {}) { |
| +function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, canEnrol = true, myName = "", planBook = null, envExtra = {} } = {}) { |
| const h = { |
| transcript: [], // { text, chips: [labels], extra } |
| verbLog: [], |
| @@ -25,6 +25,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, my |
| doneSteps: { ...done }, |
| enrolled, |
| fromHome, |
| + canEnrol, |
| myName, |
| }; |
| h.env = { |
| @@ -36,6 +37,9 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, my |
| myName: () => h.myName, |
| enrolled: () => h.enrolled, |
| fromHome: () => h.fromHome, |
| + // Whether signing in is possible at all (#1377) β false on self-hosted |
| + // spaces with no home server; the flows skip the un-actionable pitch. |
| + canEnrol: () => h.canEnrol, |
| // The plan step's price book (#1374) β mirrors apps/setup.js's getters; |
| // absent (the default) skips the step, like a self-hosted space. |
| planPrices: () => !!planBook, |
| @@ -450,6 +454,65 @@ await test("setup: native creator β greeting shows, name pre-set, passkey neve |
| assert.ok(chipLabels.includes("Open admin"), "native hops to Admin"); |
| }); |
| +await test("setup: keys answered-but-not-enrolled resumes to FINISH β never re-pitched (#1377)", async () => { |
| + // The bug: done.keys with the enrolled flag lost ("Continue" after a |
| + // detour, "I understand the risk", cleared storage) re-pitched the whole |
| + // sign-in speech on every resume and never reached markSetupComplete. |
| + const h = harness({ |
| + myName: "Paul", |
| + enrolled: false, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true, keys: true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false, "the pitch never replays"); |
| + assert.deepEqual(h.records, [{ step: "finish", label: "Your space is ready", value: "done" }]); |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "setup completes"); |
| + // Not enrolled in THIS browser: the finish reminder stays, honestly. |
| + assert.ok(h.transcript.find((t) => /One more reminder before you go/.test(t.text))); |
| + // And the finish bubble carries its stable dedup key (renames and backup |
| + // flips must not persist a second copy). |
| + assert.equal(h.transcript.find((t) => /is ready! π/.test(t.text)).extra.key, "q-finish"); |
| +}); |
| + |
| +await test("setup: no home server β the sign-in pitch vanishes, recorded honestly (#1377)", async () => { |
| + const h = harness({ |
| + myName: "Paul", |
| + canEnrol: false, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false, "no un-actionable pitch"); |
| + assert.deepEqual(h.records[0], { step: "keys", label: "This browser holds this space's key", value: "local" }); |
| + assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| + // No sign-in to nag about, and no dead Sign in chip on the finish line. |
| + assert.equal(h.transcript.some((t) => /One more reminder before you go/.test(t.text)), false); |
| + assert.equal(h.transcript.flatMap((t) => t.chips || []).includes("Sign in"), false); |
| +}); |
| + |
| +await test("welcome: no home server β the access ask vanishes, recorded honestly (#1377)", async () => { |
| + // Inline env β welcomeEnv() is declared with the welcome section below. |
| + const h = harness({ |
| + canEnrol: false, |
| + envExtra: { suggested: "", inviterName: "", adminName: "", hasPendingThread: false, space: { name: "Test Space", nameSet: true } }, |
| + }); |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => /make sure you can't lose your way in/.test(t.text)), false, "no un-actionable ask"); |
| + assert.deepEqual(h.records[0], { step: "access", label: "Your access lives in this browser", value: "local" }); |
| + // Straight to the name question instead. |
| + assert.ok(h.transcript.find((t) => /call you/.test(t.text) || /your name/i.test(t.text))); |
| +}); |
| + |
| +await test("engine: a re-derive consumes env.reentry β the NEXT resume greets again (#1377)", async () => { |
| + const h = harness({ myName: "Paul", done: { name: true, group: true }, envExtra: { reentry: true } }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => /Welcome back/.test(t.text)), false, "the re-derive itself stays quiet"); |
| + assert.equal(h.env.reentry, false, "consumed β a later genuine resume isn't silenced"); |
| +}); |
| + |
| await test("setup: the plan step β asked with a price book, priced from it, recorded on 'plan'", async () => { |
| // #1374: "who is this space for?" lives in the conversation; the create |
| // slider is never pay-gated. Names + prices interpolate from the book. |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index 9a8d244..96d5e6f 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -65,6 +65,7 @@ export const app = defineApp({ |
| 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); |
| }, |
| @@ -81,6 +82,50 @@ 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 |
| @@ -132,7 +177,9 @@ function verb(name, args, env) { |
| case "adoptServerPhoto": |
| return bridge.adoptServerPhoto(env.threadId, args[0], args[1]); |
| case "savePasskey": |
| - return bridge.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": |
| @@ -184,6 +231,9 @@ export function startSetup(env) { |
| // 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), |
| @@ -200,6 +250,7 @@ export function startSetup(env) { |
| 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) |
| @@ -221,7 +272,7 @@ export function startSetup(env) { |
| return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| }, |
| imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token), |
| - delay: (ms, fn) => setTimeout(fn, ms), |
| + 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. |
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js| index f39d00b..66d6f9b 100644 |
| --- a/web/static/apps/welcome.js |
| +++ b/web/static/apps/welcome.js |
| @@ -10,11 +10,14 @@ |
| // + 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, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; |
| +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"); |
| @@ -43,11 +46,31 @@ export const app = defineApp({ |
| 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) => { |
| @@ -68,7 +91,9 @@ function makeVerb(env) { |
| case "adoptProfilePhoto": |
| return bridge.adoptProfilePhoto(env.threadId, args[0], args[1]); |
| case "savePasskey": |
| - return bridge.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": |
| @@ -109,8 +134,15 @@ 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() }; |
| + 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 |
| @@ -121,6 +153,9 @@ export function startWelcome(env) { |
| }; |
| 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 |
| @@ -129,13 +164,20 @@ export function startWelcome(env) { |
| ask: awaitAnswer, |
| cancelAnswer, |
| verb: makeVerb(env), |
| - record: (step, label, value) => emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }), |
| + 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: (ms, fn) => setTimeout(fn, ms), |
| + delay: delayFor(env.threadId), |
| }; |
| spaceInfo() |
| .then((info) => { |
diff --git a/web/static/flow.js b/web/static/flow.js| index 11532d0..af2fe90 100644 |
| --- a/web/static/flow.js |
| +++ b/web/static/flow.js |
| @@ -152,7 +152,12 @@ export function runFlow(flow, env, io) { |
| // folded hook), not a returning member: greeting them "Welcome back" once |
| // per foreign answer would spam the feed. |
| env.resumed = evalCond(env, flow.resumedIf || false); |
| - if (env.resumed && !env.reentry |
| + // Consume the reentry flag: one re-derive suppresses one greeting. Left |
| + // set (the pre-#1377 bug) every later genuine resume in the same page life |
| + // lost its "Welcome back". |
| + const reentry = env.reentry; |
| + env.reentry = false; |
| + if (env.resumed && !reentry |
| && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) { |
| io.say(env, flow.resumeSay, null, { local: true }); |
| } |
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js| index e9bb2ab..2e73c20 100644 |
| --- a/web/static/setup.flow.js |
| +++ b/web/static/setup.flow.js |
| @@ -455,6 +455,20 @@ export default { |
| sayIfNew: "You're signed in β your space's key is safe. β ", |
| goto: "finish", |
| }, |
| + // Answered in an earlier session, but this browser isn't enrolled |
| + // ("Continue" after a detour, "I understand the risk", a lost local |
| + // flag): the step is DONE β never re-pitch it. Without this entry the |
| + // flow re-asked on every resume and never reached finish, so |
| + // markSetupComplete never ran (#1377). backedUp stays false, so the |
| + // finish line still carries the honest reminder. |
| + { if: "done", goto: "finish" }, |
| + // No home server to sign in with (self-hosted): the pitch would be a |
| + // dead-end chip β record the honest state and move on (#1377). |
| + { |
| + if: "!bridge.canEnrol", |
| + recordIfNew: { label: "This browser holds this space's key", value: "local" }, |
| + goto: "finish", |
| + }, |
| // Someone who created this space from their Eleven sign-in page already |
| // holds an account β don't pitch signing in from scratch, offer to add |
| // this space's key to it (keys-home). |
| @@ -590,9 +604,11 @@ export default { |
| "That's it β {space.name|your space} is ready! π\n\n", |
| "Everything we just set up lives in your Admin panel β invite links, the space's name and photo, who can invite. Type `/admin` anywhere to open it; it's yours alone as this space's admin.", |
| // The one honest parting warning for an admin who chose "Not now": |
| - // plain consequences, no mechanism talk, and the way out right there. |
| + // plain consequences, no mechanism talk, and the way out right |
| + // there. Gated on canEnrol β a self-hosted admin has no sign-in |
| + // to be reminded of. |
| { |
| - if: "!vars.backedUp", |
| + if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, |
| text: |
| "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it β for everyone. Sign in any time and it's safe.", |
| }, |
| @@ -604,13 +620,17 @@ export default { |
| text: "\n\nAnd the plan you picked β tap below whenever you're ready to set up the payment. Your space runs free until then.", |
| }, |
| ], |
| + // A stable dedup key (welcome has q-finish too): without one the |
| + // dedupe rides the rendered text, and a renamed space or a flipped |
| + // backup state persisted a second finish bubble (#1377). |
| + key: "q-finish", |
| }, |
| { text: "Where would you like to start?" }, |
| ], |
| chips: [ |
| // The parting reminder's action β finish-line chips stay live, and the |
| // completion poll picks the sign-in up even after the flow has ended. |
| - { if: "!vars.backedUp", label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, |
| + { if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, |
| // The chosen plan's payment (#1374): hands off to the home page's |
| // upgrade picker with the tier pre-highlighted. Same tab β setup is |
| // complete, and home brings you back to your list. |
diff --git a/web/static/shell.js b/web/static/shell.js| index d376a31..2eb0983 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -6075,6 +6075,10 @@ setSetupBridge({ |
| } |
| }, |
| markSetupComplete: async () => { |
| + // The from-home marker's one job β steering the key step's copy β is done |
| + // with setup; left behind (#1377) it steered keys-home's "your sign-in |
| + // doesn't hold it yet" forever, even long after enrolling. |
| + try { localStorage.removeItem("lchat_from_home"); } catch { /* private mode */ } |
| try { |
| return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ setup_complete: true }) })).ok; |
| } catch { |
| @@ -6401,7 +6405,19 @@ setSetupBridge({ |
| // no /admin. Gated server-side by role + the setup_complete flag, so it appears |
| // for exactly one person, until finished. |
| async function maybeStartSetup(mePromise) { |
| - const me = await mePromise; |
| + let me = await mePromise; |
| + // The boot race hands back null after 4s so a stalled /api/me never holds |
| + // the reveal hostage β but deciding "no setup" on that null silently |
| + // cancelled the whole resume (#1377): the admin came back from a successful |
| + // sign-in ceremony to no Eleven at all. The reveal already happened by the |
| + // time we're here, so a patient un-raced retry costs nothing. |
| + if (!me) { |
| + try { |
| + me = await fetch(`/api/me?token=${encodeURIComponent(session.token)}`).then((r) => r.json()); |
| + } catch { |
| + me = null; // truly unreachable β setup retries next load, as before |
| + } |
| + } |
| const setupActive = !!me && me.role === "admin" && !me.setup_complete; |
| // The sidebar backup nudge defers to Eleven's conversations entirely (setup |
| // AND the joiner welcome β one voice); afterwards it renders on its normal |
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js| index 302ed88..88d07d4 100644 |
| --- a/web/static/welcome.flow.js |
| +++ b/web/static/welcome.flow.js |
| @@ -53,6 +53,13 @@ export default { |
| sayIfNew: "Great β welcome back. π You're signed in, so your access is safe. Let's continue.", |
| goto: "name", |
| }, |
| + // No home server to sign in with (self-hosted): the ask would be a |
| + // dead-end chip β record the honest state and carry on (#1377). |
| + { |
| + if: "!bridge.canEnrol", |
| + recordIfNew: { label: "Your access lives in this browser", value: "local" }, |
| + goto: "name", |
| + }, |
| ], |
| // The sign-in ceremony returns asynchronously (a redirect back, or tabbing |
| // back from a system browser); auto-detect that return and carry on without |
| @@ -256,7 +263,7 @@ export default { |
| // The one honest parting warning for someone who chose "Not now": |
| // plain consequences, no mechanism talk, and the way out right there. |
| { |
| - if: "!accessBackedUp", |
| + if: { all: ["!accessBackedUp", "bridge.canEnrol"] }, |
| text: |
| "\n\nOne more reminder before you go: you haven't signed in yet, so your access to this space lives only in this browser β if it's cleared or lost, there's no way back in. Sign in any time and you're safe.", |
| }, |
| @@ -268,7 +275,7 @@ export default { |
| chips: [ |
| // The parting reminder's action β finish-line chips stay live, and the |
| // completion poll picks the sign-in up even after the flow has ended. |
| - { if: "!accessBackedUp", label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, |
| + { if: { all: ["!accessBackedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, |
| { |
| if: { all: ["hasPendingThread", "inviterName"] }, |
| label: "Say hi to {inviterName}", |