eleven / messenger Public

web,home: create-from-home tail — same-tab setup, auto-detected return, straight-in tour (#384)

* web,home: the create-from-home tail — same-tab setup, auto-detected return, straight-in tour

Three snags in the home → create-a-space → set-up journey:

web: "Set up your space" opened the setup link in a new tab. You're heading
there next and coming back to your list afterwards, so keep it in the same tab.

web: after saving your key mid-setup (the passkey ceremony redirects out to the
home server and back), Eleven sometimes sat on the key step waiting for a manual
"Continue" instead of noticing you were back. Two fixes: setup.js now forwards
`fromHome` into the flow's bridge (the keys-home variant was unreachable without
it), and the flow engine gains a `settle` primitive — a step can wait for a
condition to flip true on its own (an async enrolment return) and advance itself,
no tap. Wired onto the setup keys/keys-home steps and the welcome access step.

web: the "Take the tour" chip at the end of setup/welcome ran the tour's own
"Hi, fancy a tour? / Show me around" greeting — redundant when you just tapped
"Take the tour". startTour gains a skipIntro option that drops straight in; a
cold `/tutorial` still opens with the greeting.

Tests: flow engine settle + from-home routing; tutorial skipIntro; refreshed the
two stale probe-index assertions and the retired "Save a passkey" chip label.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>

* docs: changelog — onboarding passkey-return + straight-in tour

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 (1M context) <noreply@anthropic.com>
Paul Campbell 77ebd5a016147eedc30f5c73e31f106e6fa528e1 parent ca0b8b5
10 files changed, +114 −17
  • changelog/2026-07-08-onboarding-return-and-tour.md +5 −0
  • home/web/static/home.js +3 −2
  • test/flow.test.mjs +39 −8
  • test/tutorial.test.mjs +12 −0
  • web/static/apps/flow.js +17 −0
  • web/static/apps/setup.flow.js +6 −0
  • web/static/apps/setup.js +13 −2
  • web/static/apps/tutorial.js +11 −4
  • web/static/apps/welcome.flow.js +4 −0
  • web/static/apps/welcome.js +4 −1
diff --git a/changelog/2026-07-08-onboarding-return-and-tour.md b/changelog/2026-07-08-onboarding-return-and-tour.md
new file mode 100644
index 0000000..6fff40a
--- /dev/null
+++ b/changelog/2026-07-08-onboarding-return-and-tour.md
@@ -0,0 +1,5 @@
+🤖 Two little rough edges smoothed when you're getting started. When you save
+your access behind a passkey, Eleven now notices the moment you're back from the
+passkey prompt and carries straight on — no "Continue" tap to nudge it. And when
+you tap "Take the tour" at the end, it drops you straight into the tour instead
+of asking a second time whether you'd like one.
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 64585ea..b373e91 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -513,8 +513,9 @@ function showNewSpaceResult(host, setupLink) {
// pitching passkeys from scratch. Query param, not fragment: the secret
// stays alone in the fragment. (Query never carries anything sensitive.)
a.href = setupLink.replace("/join/setup#", "/join/setup?src=home#");
- a.target = "_blank";
- a.rel = "noopener";
+ // Same tab, not a new one: setting up a space is where you're headed next,
+ // and you'll come back here (to your list) once it's done — no reason to
+ // strand this page behind a second tab.
a.textContent = "Set up your space →";
const note = document.createElement("p");
note.className = "sub";
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index e746824..fae2d68 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, myName = "", envExtra = {} } = {}) {
+function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", envExtra = {} } = {}) {
const h = {
transcript: [], // { text, chips: [labels], extra }
verbLog: [],
@@ -24,6 +24,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "", envExtr
pendingDelays: [],
doneSteps: { ...done },
enrolled,
+ fromHome,
myName,
};
h.env = {
@@ -31,7 +32,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, myName = "", envExtr
respond: () => {},
typing: null,
sender: { name: "Eleven", kind: "bot" },
- bridge: { myName: () => h.myName, enrolled: () => h.enrolled },
+ bridge: { myName: () => h.myName, enrolled: () => h.enrolled, fromHome: () => h.fromHome },
space: { name: "Old Name", nameSet: false },
onEnd: () => (h.ended = true),
};
@@ -154,12 +155,14 @@ await test("setup: the full first-run transcript, happy path with detours", asyn
await h.tap("Only me");
assert.ok(h.verbLog.find((v) => v[0] === "setUserInvites" && v[1] === false));
assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text)));
- await h.tap("Save a passkey (recommended)");
+ await h.tap("Sign in");
assert.ok(h.verbLog.find((v) => v[0] === "savePasskey"));
- // The probe fires with no enrolment → the stayed-in-place waiting step.
- assert.equal(h.pendingDelays[0].ms, 1500);
- h.pendingDelays[0].fn();
+ // The probe fires with no enrolment → the stayed-in-place waiting step. (The
+ // step also arms a background settle poll; target the probe by its interval.)
+ const keysProbe = h.pendingDelays.find((d) => d.ms === 1500);
+ assert.ok(keysProbe, "the Sign in probe schedules a 1500ms check");
+ keysProbe.fn();
await tick();
assert.match(h.lastText(), /I'll wait here while you finish/);
@@ -233,6 +236,32 @@ await test("setup: already enrolled — keys short-circuits with the once-only a
assert.equal(h2.transcript.some((t) => /safely backed up/.test(t.text)), false);
});
+await test("setup: from-home key step + settle auto-detects the enrolment return", async () => {
+ const h = harness({
+ myName: "Paul",
+ fromHome: true, // arrived from the Eleven sign-in page → the keys-home variant
+ done: { name: true, group: true, email: true, photo: true, invites: true },
+ });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+
+ // fromHome routes to keys-home ("add to your passkey"), not the from-scratch pitch.
+ assert.ok(h.transcript.find((t) => /Last thing — the key/.test(t.text)), "keys-home shown");
+ assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false);
+
+ // The ceremony returns asynchronously: enrolment lands, and the settle poll —
+ // NOT a manual "Continue" tap — carries the flow on to finish.
+ const settle = h.pendingDelays.find((d) => d.ms === 700);
+ assert.ok(settle, "keys-home arms a settle poll while it waits");
+ h.enrolled = true;
+ settle.fn();
+ await tick();
+ await tick();
+ assert.deepEqual(h.records.at(-1), { step: "keys-home", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.ok(h.transcript.find((t) => /safely backed up behind your passkey. ✅/.test(t.text)));
+ assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "reached finish without a tap");
+});
+
await test("engine: template + condition mini-language", () => {
const env = { vars: { x: "hi" }, space: { name: "" }, __ack: "A" };
assert.equal(renderText(env, "{vars.x} {space.name|fallback}"), "hi fallback");
@@ -271,9 +300,11 @@ await test("welcome: invited fresh join — passkey probe, suggested name, photo
assert.match(h.transcript[0].text, /locked with keys only members hold/);
// Passkey first; the ceremony stays put → probe routes to the waiting step.
+ // (The step also arms a background settle poll; target the probe by interval.)
await h.tap("Save my access (recommended)");
- assert.equal(h.pendingDelays[0].ms, 1500);
- h.pendingDelays[0].fn();
+ const accessProbe = h.pendingDelays.find((d) => d.ms === 1500);
+ assert.ok(accessProbe, "the Save-my-access probe schedules a 1500ms check");
+ accessProbe.fn();
await tick();
assert.match(h.lastText(), /I'll wait here while you finish/);
h.enrolled = true;
diff --git a/test/tutorial.test.mjs b/test/tutorial.test.mjs
index f136e70..77746fb 100644
--- a/test/tutorial.test.mjs
+++ b/test/tutorial.test.mjs
@@ -45,6 +45,18 @@ await test("every step is attributed to Eleven and the tour ends with a 🎉", a
assert.match(steps[steps.length - 1].text, /🎉/); // celebratory ending
});
+await test("skipIntro jumps past the 'fancy a tour?' greeting straight into the tour", async () => {
+ setDefaultBot(() => null); // no Eleven to DM → the tour renders in place
+ const steps = [];
+ startTour((spec) => steps.push(spec), null, { skipIntro: true });
+ await tick(); // spaceInfo().finally → toElevenDm(elevenTour resolves null)
+ await tick(); // → the first real step
+ assert.ok(steps.length >= 1, "a step rendered");
+ assert.doesNotMatch(steps[0].text, /Fancy a quick tour/); // the greeting is skipped
+ assert.doesNotMatch(steps[0].text, /your guide to this app/); // ...and its intro line
+ assert.match(steps[0].text, /You're in /); // it opens on the space step instead
+});
+
await test("the default path skips the security deep-dive; picking it shows it", async () => {
setDefaultBot(() => null);
const plain = textOf(await walk()); // taps "No thanks" at the privacy step
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js
index cc106cd..75b3f27 100644
--- a/web/static/apps/flow.js
+++ b/web/static/apps/flow.js
@@ -280,6 +280,23 @@ export function runFlow(flow, env, io) {
io.say(env, "", buildChips(stepCtx, stepCtx.go), {});
}
armInput(stepCtx);
+ // A step may WAIT for a condition to become true on its own — the passkey
+ // ceremony returns asynchronously (a redirect back, or the user tabbing back
+ // from a system browser), so `bridge.enrolled` can flip true seconds after
+ // we've shown the pitch. Poll it while the step is on screen and, the moment
+ // it holds, re-enter the step so its entries fire (record the backup, advance)
+ // — no manual "Continue" tap. The shared onceStep guard means a chip tap still
+ // wins and stops the poll.
+ if (step.settle && !evalCond(env, step.settle.when)) {
+ const go = stepCtx.go;
+ const period = step.settle.everyMs || 700;
+ const tick = () => {
+ if (go.taken) return; // a chip/advance already took the step
+ if (evalCond(env, step.settle.when)) return go(() => enter(step.settle.goto || step.id));
+ io.delay(period, tick);
+ };
+ io.delay(period, tick);
+ }
// A bubble-only step may auto-advance (welcome's greeting → the passkey
// step); input/chip steps advance through answers instead. ("auto", not
// "then" — text conditionals already use then/else.)
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index ff3d1b1..c9f770e 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -184,6 +184,9 @@ export default {
// this space's key to theirs (keys-home).
{ if: "bridge.fromHome", goto: "keys-home" },
],
+ // The passkey ceremony returns asynchronously (a redirect back from the
+ // home server); auto-detect that return and advance without a manual tap.
+ settle: { when: "bridge.enrolled" },
bubbles: [
{
text:
@@ -218,6 +221,9 @@ export default {
goto: "finish",
},
],
+ // Auto-detect the return from the home server's passkey ceremony (same as
+ // the keys step above) so the user is carried on without tapping Continue.
+ settle: { when: "bridge.enrolled" },
bubbles: [
{
text:
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js
index 3ada898..8d55b79 100644
--- a/web/static/apps/setup.js
+++ b/web/static/apps/setup.js
@@ -80,7 +80,10 @@ function verb(name, args, env) {
case "openAdmin":
return openAppThread("admin");
case "startTour":
- return startTour(env.respond, env.typing);
+ // 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}`);
}
@@ -94,7 +97,15 @@ export function startSetup(env) {
if (!bridge || running.has(env.threadId)) return;
running.add(env.threadId);
env.sender = { name: "Eleven", kind: "bot" };
- env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled() };
+ 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(),
+ };
env.onEnd = () => running.delete(env.threadId);
const io = {
say,
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js
index d7ef9f8..68faec6 100644
--- a/web/static/apps/tutorial.js
+++ b/web/static/apps/tutorial.js
@@ -22,9 +22,13 @@ registerFloorCommand("/tutorial", {
run: (_args, _threadId, reply) => startTour(reply),
});
-// startTour(respond): respond(spec) appends one Eleven notice to the current feed.
-// Both entry points supply their own respond bound to the thread you're in.
-export function startTour(respond, typing) {
+// startTour(respond, typing, opts): respond(spec) appends one Eleven notice to
+// the current feed. Both entry points supply their own respond bound to the
+// thread you're in. opts.skipIntro jumps past the tour's own "Hi, fancy a tour?"
+// greeting straight into it — used when a conversation's "Take the tour" chip
+// already asked (the user has just been talking to Eleven; a fresh /tutorial
+// keeps the greeting).
+export function startTour(respond, typing, opts = {}) {
const bot = defaultBot();
// With a typing hook (the setup/welcome conversations pass theirs), each tour
// reply gets a short beat first — same voice, same rhythm as Eleven's script.
@@ -52,12 +56,15 @@ export function startTour(respond, typing) {
};
// Fetch the space name + administrator identity, then open — but never block the
// tour on it: if the read fails, we degrade to a nameless "this space".
+ // skipIntro drops straight into the tour proper (in the Eleven DM, exactly as
+ // the greeting's "Show me around" would have); otherwise it opens with the
+ // greeting.
spaceInfo()
.then((info) => {
env.space = info || {};
})
.catch(() => {})
- .finally(() => stepWelcome(env));
+ .finally(() => (opts.skipIntro ? toElevenDm(env, () => stepSpace(env)) : stepWelcome(env)));
}
// A per-step guard: the first advancing tap wins, later taps are ignored. The
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js
index 987fedd..c4f4f2c 100644
--- a/web/static/apps/welcome.flow.js
+++ b/web/static/apps/welcome.flow.js
@@ -47,6 +47,10 @@ export default {
goto: "name",
},
],
+ // The passkey ceremony returns asynchronously (a redirect back, or tabbing
+ // back from a system browser); auto-detect that return and carry on without
+ // a manual "Continue" tap.
+ settle: { when: "bridge.enrolled" },
// One bubble, chips attached — context and ask together, so the opening
// run is two messages (intro + this), not three.
bubbles: [
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
index 4f1e7a8..1810244 100644
--- a/web/static/apps/welcome.js
+++ b/web/static/apps/welcome.js
@@ -73,7 +73,10 @@ function makeVerb(env) {
case "openPendingThread":
return env.openPendingThread && env.openPendingThread();
case "startTour":
- return startTour(env.respond, env.typing);
+ // 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}`);
}