eleven / messenger Public

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>
Paul Campbell d020a82ffb37e2b8c5313745a2e37662a9e05409 parent 1e7a47e
10 files changed, +271 −20
  • changelog/2026-07-28-eleven-signin-detour.md +6 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +26 −3
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +20 −2
  • test/flow.test.mjs +64 −1
  • web/static/apps/setup.js +53 −2
  • web/static/apps/welcome.js +47 −5
  • web/static/flow.js +6 −1
  • web/static/setup.flow.js +23 −3
  • web/static/shell.js +17 −1
  • web/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}",