web,agent: /tutorial — Eleven's guided tour (#212)
* web,agent: /tutorial — Eleven's guided tour
A client-driven, Eleven-voiced onboarding tour: a sequence of private
"only visible to you" notices with tap-to-advance chips, rendered in place.
It's the interactive successor to the static welcome DM — the welcome now
carries a "Take the tour" chip that starts it (a new harmless open_tutorial
chip type), and /tutorial runs it anytime, in any thread.
Client-side by design: it must work with no AI and even no bot, it's stateful
and interactive, and advancing/ending are client concerns. Eleven owns the
voice (notices are attributed to it); a member with AI on can still just ask.
Flow: intro → this space (group name + optional administrator identity, read
member-gated; degrades to "privately run") → To-do → Reminders (each with an
"Open …" side-trip) → privacy, with a "Tell me about security" branch → an
AI section shown only where a conversational helper is present → a 🎉 ending.
Plumbing: a floor-command registry in apps.js (client-answered, never sent to
a bot, listed in the "/" typeahead everywhere) + small exports (defaultBot,
openAppThread, spaceInfo); chat.js gains appendNotice so the welcome chip can
answer in place; shell.js wires the open_tutorial runner + the space-info read.
Tested: test/tutorial.test.mjs drives the flow (steps, security branch, AI
gating, tap-twice idempotency, /tutorial routing); ui.test.mjs covers the new
chip in the whitelist. Full npm test + go build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: run the tour in the Eleven DM + a way back after wandering off
Two bits of feedback from trying it:
- "Show me around" now moves the tour into your DM with Eleven and continues
there (find-or-create the DM, open it, render the rest into it). The intro
still answers in place; the tap is what navigates — the app's rule. apps.js
gains an injected elevenTour() the shell implements from the roster + DM
create/open, and chat.js's appendNotice queues a notice until the target
thread mounts (so the first post-navigation step isn't lost to the feed load).
- The To-do / Reminders steps now nudge you to try the command yourself
(/todo buy milk, /remind …) and reassure you won't lose your place: the tour
stays in the chat with Eleven, so you can wander off and come back to this
conversation to continue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: one affirmative security chip; friendlier skip label
The privacy step had two chips ("Tell me about security" + "Yes, tell me
more") that did the same thing — collapse to one. Rename the skip from
"No thanks" to "Sounds secure enough to me!".
* web: code-style command examples; richer AI step
- Notice bodies now render the same safe inline formatting as chat messages
(via richTokens → text nodes + a single wrapper each, never innerHTML), so a
command example in `backticks` shows as monospace. Command examples in the
tour are backticked; added .local-notice-body code/link CSS.
- AI step: invite a free-form question ("try asking me anything about the
app!"), add a "Tell me about Eleven" chip that sends a REAL question to
Eleven so the AI actually replies (elevenTour now returns { notice, ask };
shell.js sendChatText posts as the member into the Eleven DM), and rename
"Finish" to "End tutorial".
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 files changed,
+495
−11
agent/eleven.mjs+10 −7changelog/2026-07-05-eleven-tutorial.md+1 −0package.json+1 −1test/tutorial.test.mjs+111 −0test/ui.test.mjs+2 −1web/static/apps.js+86 −1web/static/apps/chat.js+29 −0web/static/apps/tutorial.js+191 −0web/static/shell.js+55 −1web/static/style.css+8 −0web/static/ui.js+1 −0
diff --git a/agent/eleven.mjs b/agent/eleven.mjs| index 1ac48d6..7395c6d 100644 |
| --- a/agent/eleven.mjs |
| +++ b/agent/eleven.mjs |
| @@ -83,13 +83,16 @@ export async function loadElevenSession() { |
| // actually run — so the welcome never promises what this instance can't do. |
| const welcomeText = (name, aiEnabled) => |
| `Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` + |
| - `A few things worth knowing:\n` + |
| - `• Everything here is end-to-end encrypted — only the people in a chat can read it.\n` + |
| - `• Tap ➕ to start a chat or a group. Helpers (like me) have their own section there.\n` + |
| - `• Double-tap a message to react, swipe right to reply.\n\n` + |
| + `Everything here is end-to-end encrypted — only the people in a chat can read it, never the server.\n\n` + |
| + `Tap “Take the tour” below for a quick guided walkthrough (or type /tutorial anytime). ` + |
| (aiEnabled |
| - ? `Ask me anything about how the app works — I'm happy to help!` |
| - : `Try /help to see what I can do.`); |
| + ? `You can also just ask me anything about how the app works.` |
| + : `And /help lists what I can do.`); |
| + |
| +// The welcome offers a one-tap chip that starts the client-side guided tour |
| +// (tutorial.js). It's a harmless client action, so it rides even on an |
| +// admin-customised welcome — the tour is useful either way. |
| +const WELCOME_ACTIONS = [{ type: "open_tutorial" }]; |
| // Member display names are encrypted under the instance key (the server is |
| // blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to |
| @@ -182,7 +185,7 @@ export async function welcomeTick() { |
| } |
| const name = await memberName(person, instanceKey); |
| const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED); |
| - const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now() }); |
| + const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now(), actions: WELCOME_ACTIONS }); |
| const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
diff --git a/changelog/2026-07-05-eleven-tutorial.md b/changelog/2026-07-05-eleven-tutorial.md| new file mode 100644 |
| index 0000000..3358a32 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-eleven-tutorial.md |
| @@ -0,0 +1 @@ |
| +New: a guided tour from Eleven. Type /tutorial anytime — or tap “Take the tour” when Eleven first welcomes you — for a quick, tappable walkthrough: what this space is and who runs it, how To-dos and Reminders work (with a nudge to try each), and a gentle explainer of how everything stays private and encrypted. It ends with a little 🎉. |
diff --git a/package.json b/package.json| index c98c741..81847ef 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs" |
| } |
| } |
diff --git a/test/tutorial.test.mjs b/test/tutorial.test.mjs| new file mode 100644 |
| index 0000000..f136e70 |
| --- /dev/null |
| +++ b/test/tutorial.test.mjs |
| @@ -0,0 +1,111 @@ |
| +// Tests for Eleven's guided tour (client-side flow). node test/tutorial.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { startTour } from "../web/static/apps/tutorial.js"; |
| +import { runCommand, commandCatalog, setDefaultBot } from "../web/static/apps.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log(`PASS ${name}`); |
| + } catch (e) { |
| + failures++; |
| + console.log(`FAIL ${name} — ${e && e.message}`); |
| + } |
| +} |
| + |
| +const tick = () => new Promise((r) => setTimeout(r, 0)); |
| +const lastChip = (spec) => spec.actions[spec.actions.length - 1]; // the advancing chip |
| +const textOf = (steps) => steps.map((s) => s.text).join("\n"); |
| + |
| +// Walk the tour, tapping one chip per step (chooser picks which). Stops at the |
| +// first step with no chips (the ending). Awaits a tick after each tap since an |
| +// advance can be async (e.g. "Show me around" navigates to the Eleven DM first). |
| +async function walk(chooser = lastChip) { |
| + const steps = []; |
| + startTour((spec) => steps.push(spec)); |
| + await tick(); // spaceInfo().finally → first step |
| + for (let guard = 0; guard < 20; guard++) { |
| + const cur = steps[steps.length - 1]; |
| + if (!cur.actions || !cur.actions.length) break; // the ending |
| + const before = steps.length; |
| + chooser(cur).run(); |
| + await tick(); |
| + if (steps.length === before) break; // tapped a non-advancing chip; stop |
| + } |
| + return steps; |
| +} |
| + |
| +await test("every step is attributed to Eleven and the tour ends with a 🎉", async () => { |
| + setDefaultBot(() => null); // no bot → speaks as "Eleven", AI section off |
| + const steps = await walk(); |
| + assert.ok(steps.length >= 5, `expected a multi-step tour, got ${steps.length}`); |
| + for (const s of steps) assert.equal(s.sender.name, "Eleven"); |
| + assert.match(steps[0].text, /I'm Eleven/); // intro introduces itself |
| + assert.match(steps[steps.length - 1].text, /🎉/); // celebratory ending |
| +}); |
| + |
| +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 |
| + assert.doesNotMatch(plain, /never leaves your device/); |
| + |
| + // Now tap the FIRST chip at the privacy step ("Tell me about security"). |
| + const security = textOf( |
| + await walk((spec) => (/technical version/.test(spec.text) ? spec.actions[0] : lastChip(spec))), |
| + ); |
| + assert.match(security, /never leaves your device/); |
| +}); |
| + |
| +await test("the AI section appears only where a conversational helper is present", async () => { |
| + setDefaultBot(() => null); |
| + assert.doesNotMatch(textOf(await walk()), /AI helpers switched on/); |
| + |
| + setDefaultBot(() => ({ name: "Eleven", kind: "ai" })); |
| + const withAi = textOf(await walk()); |
| + assert.match(withAi, /AI helpers switched on/); |
| + assert.match(withAi, /asking me anything/i); // invites a free-form question |
| + setDefaultBot(() => null); |
| +}); |
| + |
| +await test("command examples are wrapped in backticks so they render as code", async () => { |
| + setDefaultBot(() => null); |
| + const all = textOf(await walk()); |
| + assert.match(all, /`\/todo buy milk`/); |
| + assert.match(all, /`\/help`/); |
| +}); |
| + |
| +await test("tapping an advancing chip twice can't double-advance", async () => { |
| + setDefaultBot(() => null); |
| + const steps = []; |
| + startTour((spec) => steps.push(spec)); |
| + await tick(); |
| + const chip = lastChip(steps[0]); // "Show me around" — fires once even if tapped twice |
| + chip.run(); |
| + chip.run(); // stale tap |
| + await tick(); |
| + assert.equal(steps.length, 2, "a second tap on the same chip appended another step"); |
| +}); |
| + |
| +await test("the Todo/Reminder steps invite trying the command and reassure you can come back", async () => { |
| + setDefaultBot(() => null); |
| + const all = textOf(await walk()); |
| + assert.match(all, /\/todo /); // shows how to try it yourself |
| + assert.match(all, /come back to this conversation/i); // reassurance that you won't lose your place |
| +}); |
| + |
| +await test("/tutorial is a client-answered command, listed everywhere in the typeahead", async () => { |
| + const entry = commandCatalog().find((c) => c.name === "/tutorial"); |
| + assert.ok(entry, "/tutorial missing from the command catalog"); |
| + assert.equal(entry.bot, false); // client-answered → shown in every thread |
| + |
| + setDefaultBot(() => null); |
| + const steps = []; |
| + const handled = runCommand("/tutorial", "thread-1", (spec) => steps.push(spec)); |
| + assert.equal(handled, true); // caught, not broadcast |
| + await tick(); |
| + assert.match(steps[0].text, /I'm Eleven/); |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 892225b..5bdd2a6 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -209,9 +209,10 @@ await test("sanitizeActions: whitelists types and validates params", () => { |
| { type: "set_appearance", appearance: "dark", label: " Go dark " }, |
| { type: "delete_everything" }, // unknown type |
| { type: "open_profile" }, |
| + { type: "open_tutorial" }, // the welcome DM's "Take the tour" chip |
| "junk", |
| ]); |
| - assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile"]); |
| + assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile", "open_tutorial"]); |
| assert.equal(chips[0].theme, "plum"); |
| assert.equal(chips[1].label, "Go dark"); |
| }); |
diff --git a/web/static/apps.js b/web/static/apps.js| index ee8ca73..5a9e853 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -22,6 +22,7 @@ |
| // reducer shape here is deliberately snapshot-ready. |
| import { BOT_COMMANDS } from "./bot-commands.js"; |
| +import { richTokens } from "./format.js"; |
| const apps = []; |
| const typeOwner = new Map(); // event type → owning app |
| @@ -526,6 +527,46 @@ let hostBridge = null; |
| export function setHostBridge(b) { |
| hostBridge = b; |
| } |
| +// Space info for Eleven's guided tour: a promise of { name, adminName, adminEmail } |
| +// — the group name plus the optional administrator identity (a member-gated read). |
| +// shell.js injects the fetch; absent or failing → the tour degrades to just a name. |
| +let spaceInfoFn = null; |
| +export function setSpaceInfo(fn) { |
| + spaceInfoFn = fn; |
| +} |
| +export function spaceInfo() { |
| + if (!spaceInfoFn) return Promise.resolve({}); |
| + return Promise.resolve() |
| + .then(() => spaceInfoFn()) |
| + .catch(() => ({})); |
| +} |
| +// The default bot (Eleven), for modules that speak in Eleven's voice or need to |
| +// know whether conversational AI is on here (kind === "ai"). Null when no bot. |
| +export function defaultBot() { |
| + return defaultBotFn ? defaultBotFn() : null; |
| +} |
| +// Open a personal app's own thread (created on first use) — for a chip that invites |
| +// someone to try To-do / Reminders. Leaving the current context is always their tap. |
| +export async function openAppThread(name) { |
| + if (!resolveAppThread || !openThreadFn) return; |
| + const id = await resolveAppThread(name); |
| + if (id) openThreadFn(id); |
| +} |
| +// The guided tour lives in your DM with Eleven. When "Show me around" is tapped |
| +// from anywhere else, this navigates there (finding or creating the DM) and |
| +// returns a notice sink bound to it, so the rest of the tour renders in the DM. |
| +// Returns null when there's no Eleven to DM (the tour then just stays in place). |
| +// shell.js injects the implementation (roster + DM create + open live there). |
| +let elevenTourFn = null; |
| +export function setElevenTour(fn) { |
| + elevenTourFn = fn; |
| +} |
| +export function elevenTour() { |
| + if (!elevenTourFn) return Promise.resolve(null); |
| + return Promise.resolve() |
| + .then(() => elevenTourFn()) |
| + .catch(() => null); |
| +} |
| function makeCtx(threadId, app) { |
| return { |
| @@ -588,6 +629,16 @@ function makeCtx(threadId, app) { |
| // commandCatalog flags them non-bot so the typeahead shows them everywhere. |
| const CLIENT_ANSWERED = new Set(["/help"]); |
| +// Floor commands the CLIENT answers in place, owned by no app and never sent to a |
| +// bot (like /help, but contributed by a feature module — e.g. /tutorial). Each is |
| +// run(args, threadId, reply), rendering private notices via reply. Kept apart from |
| +// BOT_COMMANDS so the sidecar never sees a command it has no handler for, and |
| +// listed in the "/" typeahead everywhere (bot:false), like the client-answered set. |
| +const floorCommands = new Map(); // name -> { desc, args, run(args, threadId, reply) } |
| +export function registerFloorCommand(name, spec) { |
| + floorCommands.set(name, { desc: (spec && spec.desc) || "", args: (spec && spec.args) || "", run: spec && spec.run }); |
| +} |
| + |
| // helpNotice lists the commands you can use, as the private "/help" reply. Mirrors |
| // the typeahead: the bot's commands (/theme, …) show only where a bot can answer |
| // them, so /help never advertises a command that would do nothing here. |
| @@ -634,6 +685,12 @@ export function runCommand(text, activeThreadId, respond) { |
| } |
| }; |
| if (!cmd.app) { |
| + // A registered floor command (e.g. /tutorial) answers itself, in place. |
| + const floor = floorCommands.get(cmd.name); |
| + if (floor && floor.run) { |
| + floor.run(cmd.args, activeThreadId, reply); |
| + return true; |
| + } |
| // /help (and any other CLIENT_ANSWERED floor command) is answered right here |
| // with a private notice — no bot needed, so it works in every thread. |
| if (cmd.name === "/help") { |
| @@ -675,6 +732,8 @@ export function commandCatalog() { |
| args: (app.commands[name] && app.commands[name].args) || "", |
| bot: false, |
| })); |
| + // Floor commands (e.g. /tutorial) are client-answered → shown in every thread. |
| + for (const [name, c] of floorCommands) out.push({ name, desc: c.desc, args: c.args, bot: false }); |
| // bot:false for the ones the client answers itself (/help) → the typeahead lists |
| // them in every thread, not only where a bot is present. |
| for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: !CLIENT_ANSWERED.has(name) }); |
| @@ -735,6 +794,32 @@ function motionOK() { |
| // name row), the reply text is a chat-style bubble, and any trusted action chips |
| // ({ label, run } — minted locally, so they bypass the untrusted-payload |
| // whitelist) sit below. |
| +// |
| +// Notice bodies render the same safe inline formatting as chat messages, so a |
| +// command example in `backticks` shows as monospace code (and **bold**, links, |
| +// etc. work too). Built from richTokens as text nodes + a single wrapper element |
| +// each — never innerHTML, so untrusted text (a to-do name in a confirmation) |
| +// stays inert. |
| +const NOTICE_MARK_TAG = { b: "strong", i: "em", s: "s", code: "code" }; |
| +function renderNoticeBody(text, el) { |
| + for (const tok of richTokens(text)) { |
| + if (tok.href) { |
| + const a = document.createElement("a"); |
| + a.href = tok.href; |
| + a.target = "_blank"; |
| + a.rel = "noopener noreferrer"; |
| + a.textContent = tok.text; |
| + el.appendChild(a); |
| + } else if (tok.mark) { |
| + const w = document.createElement(NOTICE_MARK_TAG[tok.mark]); |
| + w.textContent = tok.text; |
| + el.appendChild(w); |
| + } else { |
| + el.appendChild(document.createTextNode(tok.text)); |
| + } |
| + } |
| +} |
| + |
| function fillFullNotice(row, spec = {}) { |
| const { sender, text, actions } = spec; |
| const head = document.createElement("div"); |
| @@ -767,7 +852,7 @@ function fillFullNotice(row, spec = {}) { |
| bubble.className = "local-notice-bubble"; |
| const body = document.createElement("div"); |
| body.className = "local-notice-body"; |
| - body.textContent = text || ""; |
| + renderNoticeBody(text || "", body); |
| bubble.appendChild(body); |
| row.appendChild(bubble); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 1c22317..c309157 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -69,8 +69,26 @@ const refreshTyping = new Map(); // threadId → fn redrawing the typing row |
| // it. render() folds these into its keyedList (see the notice branch in renderItem). |
| const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }] |
| const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane |
| +const mountedCtx = new Map(); // threadId → live app ctx (for shell-injected notices) |
| +const pendingNotices = new Map(); // threadId → [spec] queued until that thread mounts |
| let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread |
| +// appendNotice injects a private "only visible to you" notice into a thread's feed |
| +// from OUTSIDE a command (the shell uses it when an action chip answers in place — |
| +// the welcome's "Take the tour", and the tour continuing after it navigates you to |
| +// the Eleven DM). If the target thread isn't mounted yet (we just navigated to it |
| +// and its feed is still loading), the notice is queued and flushed when it mounts. |
| +export function appendNotice(threadId, spec) { |
| + const ctx = mountedCtx.get(threadId); |
| + if (ctx) { |
| + addLocalNotice(ctx, spec); |
| + return; |
| + } |
| + const q = pendingNotices.get(threadId) || []; |
| + q.push(spec); |
| + pendingNotices.set(threadId, q); |
| +} |
| + |
| // addLocalNotice records a command reply for `threadId` and re-renders the pane so |
| // it appears immediately. Anchored just above the current newest message so it |
| // sits where you typed it and never jumps around as the conversation continues. |
| @@ -373,8 +391,19 @@ export const app = defineApp({ |
| // (like the hotkeys/native re-points above), self-removed on teardown. |
| const doRefresh = () => renderApp(ctx.threadId, "chat", el); |
| refreshFeed.set(ctx.threadId, doRefresh); |
| + // Keep the live mount ctx so the shell can inject a private notice into this |
| + // thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour). |
| + mountedCtx.set(ctx.threadId, ctx); |
| + // Flush any notices that were queued for this thread before it mounted (the |
| + // tour navigating to the Eleven DM appends its next step while the feed loads). |
| + const queued = pendingNotices.get(ctx.threadId); |
| + if (queued) { |
| + pendingNotices.delete(ctx.threadId); |
| + for (const spec of queued) addLocalNotice(ctx, spec); |
| + } |
| ctx.onCleanup(() => { |
| if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId); |
| + if (mountedCtx.get(ctx.threadId) === ctx) mountedCtx.delete(ctx.threadId); |
| }); |
| const feed = root.querySelector(".chatapp-feed"); |
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js| new file mode 100644 |
| index 0000000..97e9d8f |
| --- /dev/null |
| +++ b/web/static/apps/tutorial.js |
| @@ -0,0 +1,191 @@ |
| +// /tutorial — Eleven's guided tour. A client-driven, in-Eleven's-voice onboarding |
| +// flow: a sequence of private "only visible to you" notices with tap-to-advance |
| +// chips, rendered right where you are. It's the interactive successor to the |
| +// static welcome DM (the welcome now offers a "Take the tour" chip that starts it). |
| +// |
| +// Why client-side rather than a bot conversation: the tour must work with NO AI |
| +// (it's the app basics) and even with no bot at all; it's stateful and interactive; |
| +// and two of its behaviours — advancing on a tap, ending with a flourish — are |
| +// inherently client concerns. Eleven still owns the voice (the notices are |
| +// attributed to it), and a member with conversational AI on can always just ask. |
| +// |
| +// Nothing here is sent to the server or to other members — notices are local and |
| +// ephemeral (see buildLocalNotice). The tour reads the group name + optional |
| +// administrator identity via spaceInfo(), and shows the AI section only when a |
| +// conversational helper is present. |
| +import { registerFloorCommand, defaultBot, openAppThread, spaceInfo, elevenTour } from "../apps.js"; |
| + |
| +// The tour is entered two ways — typing /tutorial, and the welcome DM's "Take the |
| +// tour" chip — so registration lives here and startTour is exported for the chip. |
| +registerFloorCommand("/tutorial", { |
| + desc: "Take a quick guided tour", |
| + 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) { |
| + const bot = defaultBot(); |
| + const env = { |
| + respond, |
| + // Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise |
| + // still speak as "Eleven" the guide — the tour is a client feature, not a bot. |
| + sender: bot || { name: "Eleven", kind: "bot" }, |
| + ai: !!bot && bot.kind === "ai", |
| + space: {}, |
| + }; |
| + // 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". |
| + spaceInfo() |
| + .then((info) => { |
| + env.space = info || {}; |
| + }) |
| + .catch(() => {}) |
| + .finally(() => stepWelcome(env)); |
| +} |
| + |
| +// A per-step guard: the first advancing tap wins, later taps are ignored. The |
| +// notice (and its chips) are rebuilt on every feed refresh, so without this a |
| +// second tap on a stale "Next" would append the same step twice. |
| +function onceStep() { |
| + let done = false; |
| + return (fn) => { |
| + if (done) return; |
| + done = true; |
| + fn(); |
| + }; |
| +} |
| + |
| +// advance = a chip that moves the tour on (guarded); side = a chip that does |
| +// something without advancing (open an app), so it stays tappable if you return. |
| +const advance = (go, label, fn) => ({ label, run: () => go(fn) }); |
| +const side = (label, fn) => ({ label, run: fn }); |
| + |
| +function say(env, text, actions) { |
| + env.respond({ sender: env.sender, text, actions }); |
| +} |
| + |
| +function stepWelcome(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Hi — I'm Eleven, your guide to this app (yes, I'm named after it 🙂).\n\n" + |
| + "Eleven is a private group chat: every message is end-to-end encrypted, so only the people in a chat can read it — never the server, and never whoever runs it. " + |
| + "A few things you can do anytime: type `/help` to see the commands, `/theme` to change the colours, or just ask me a question.\n\n" + |
| + "Fancy a quick tour? I'll run it here in our chat.", |
| + [advance(go, "Show me around →", () => toElevenDm(env, () => stepSpace(env)))], |
| + ); |
| +} |
| + |
| +// The tour belongs in your DM with Eleven. "Show me around" moves it there (from |
| +// wherever it was started) and continues; from then on every step renders in that |
| +// chat, so you can wander off to try something and come back to it. If there's no |
| +// Eleven to DM, we just carry on in place. |
| +function toElevenDm(env, next) { |
| + elevenTour() |
| + .then((tour) => { |
| + if (tour) { |
| + env.respond = tour.notice; // render the rest of the tour into the Eleven DM |
| + env.ask = tour.ask; // and let the AI step ask Eleven a real question there |
| + } |
| + }) |
| + .catch(() => {}) |
| + .finally(next); |
| +} |
| + |
| +function stepSpace(env) { |
| + const go = onceStep(); |
| + const name = env.space.name || "this space"; |
| + let text = `You're in ${name}. `; |
| + if (env.space.adminName) { |
| + text += `It's looked after by ${env.space.adminName}`; |
| + text += env.space.adminEmail ? ` — you can reach them at ${env.space.adminEmail}.` : "."; |
| + } else { |
| + text += "It's privately run — no company in the middle, just the people here."; |
| + } |
| + text += |
| + "\n\nEvery space is its own separate, isolated world: its own members, its own keys, nothing shared with anywhere else."; |
| + say(env, text, [advance(go, "Next →", () => stepTodo(env))]); |
| +} |
| + |
| +function stepTodo(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Let me show you two handy built-ins.\n\n" + |
| + "First — To-do, your own private checklist. Go ahead and try it now: type `/todo buy milk` in any chat (or tap “Open To-do” below). It files into your own list — encrypted, and synced across your devices.\n\n" + |
| + "Don't worry about losing your place: our tour stays right here in this chat with me. When you're ready to come back, just come back to this conversation — or tap “Next” to carry on.", |
| + [side("Open To-do", () => openAppThread("todo")), advance(go, "Next →", () => stepRemind(env))], |
| + ); |
| +} |
| + |
| +function stepRemind(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "And Reminders — for nudging yourself later. Try typing `/remind call mum in 10 minutes` (or `/remind standup at 9:00`). " + |
| + "It'll message you at the right moment, on every device — and even the reminder's text and time stay private to you.\n\n" + |
| + "Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.", |
| + [side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))], |
| + ); |
| +} |
| + |
| +function stepPrivacy(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Now the part that matters most: privacy.\n\n" + |
| + "Everything here — messages, photos, names, files — is end-to-end encrypted. The server only ever stores scrambled data it can't unlock, so even whoever runs this space can never read your chats.\n\n" + |
| + "Want the slightly more technical version?", |
| + [ |
| + side("Tell me about security", () => go(() => stepSecurity(env))), |
| + side("Sounds secure enough to me!", () => go(() => afterPrivacy(env))), |
| + ], |
| + ); |
| +} |
| + |
| +function stepSecurity(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Here's how it works:\n\n" + |
| + "• You have a personal key that never leaves your device.\n" + |
| + "• Every chat has its own key, locked to each member's key — so only its members can open it.\n" + |
| + "• Photos are shrunk and stripped of location data before they're encrypted and uploaded.\n" + |
| + "• Invite links carry a one-time secret in the part of the address your browser never sends to the server.\n\n" + |
| + "The one thing the server sees in the clear is a small preview card when someone posts a web link — a deliberate, single exception. Everything else, it simply can't read.", |
| + [advance(go, "Got it →", () => afterPrivacy(env))], |
| + ); |
| +} |
| + |
| +// After privacy, the AI section only appears where a conversational helper is on. |
| +function afterPrivacy(env) { |
| + if (env.ai) stepAI(env); |
| + else stepEnd(env); |
| +} |
| + |
| +function stepAI(env) { |
| + const go = onceStep(); |
| + const chips = []; |
| + // Where we can post as the member (the tour's in the Eleven DM), offer a chip |
| + // that actually asks Eleven — a real question, so the AI genuinely replies. |
| + if (env.ask) chips.push(side("Tell me about Eleven", () => env.ask("Tell me about Eleven — what makes this app different?"))); |
| + chips.push(advance(go, "End tutorial", () => stepEnd(env))); |
| + say( |
| + env, |
| + "One more thing — this space has AI helpers switched on. 🤖\n\n" + |
| + "Beyond the quick commands, you can actually chat with a helper like me: ask questions, think something through, get a hand. Try asking me anything about the app!\n\n" + |
| + "Soon you'll be able to give helpers skills, and even coordinate a few of them together — that part's just getting started. 👀", |
| + chips, |
| + ); |
| +} |
| + |
| +function stepEnd(env) { |
| + const name = env.space.name || "here"; |
| + say( |
| + env, |
| + `That's the tour! 🎉\n\n` + |
| + `You're all set. Type \`/help\` whenever you'd like a reminder of what I can do — or just say hi. Enjoy ${name}!`, |
| + // A gentle ending — no more chips to tap. |
| + ); |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 45c7d5b..0ce5c88 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -52,6 +52,8 @@ import { |
| setThreadHasBot, |
| setThreadOpener, |
| setHostBridge, |
| + setSpaceInfo, |
| + setElevenTour, |
| runCommand, |
| commandCatalog, |
| buildLocalNotice, |
| @@ -82,7 +84,8 @@ import "/static/apps/bookmarks.js"; |
| import "/static/apps/poll.js"; |
| import "/static/apps/board.js"; |
| import "/static/apps/connect.js"; |
| -import { setHost as setChatAppHost, setConnected as setChatAppConnected } from "/static/apps/chat.js"; |
| +import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command |
| +import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice } from "/static/apps/chat.js"; |
| import { parseInviteFragment, buildInviteFragment } from "/static/invite.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn } from "/static/backup.js"; |
| @@ -171,6 +174,10 @@ const CHIP_RUNNERS = { |
| }, |
| open_theme_picker: () => (showThemePicker(), null), |
| open_profile: () => (editProfile(), null), |
| + // Start Eleven's guided tour in the thread the chip was tapped in (the welcome |
| + // DM). The tour renders as private notices via chat.js's appendNotice; the chip |
| + // stays as-is (returns null), since the tour appears below it in the feed. |
| + open_tutorial: () => (startTour((spec) => appendNotice(activeId, spec)), null), |
| }; |
| const chipLabel = (a) => |
| a.label || |
| @@ -179,6 +186,7 @@ const chipLabel = (a) => |
| set_appearance: { light: "Switch to light", dark: "Switch to dark", system: "Follow my device" }[a.appearance], |
| open_theme_picker: "Open theme picker", |
| open_profile: "Edit my profile", |
| + open_tutorial: "Take the tour", |
| }[a.type]; |
| function buildActionChips(actions) { |
| @@ -2397,6 +2405,52 @@ setDefaultBot(() => { |
| // Lets /help list the bot's commands only where a bot can answer them (same gate |
| // as the "/" typeahead). |
| setThreadHasBot((id) => threadHasBot(id)); |
| +// Space info for Eleven's guided tour: the group name plus the optional |
| +// administrator identity. The admin fields are member-gated, so read /api/group |
| +// with our token; the tour degrades to just the name if it's unset or fails. |
| +setSpaceInfo(async () => { |
| + try { |
| + const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + return { name: g.name || "", adminName: g.admin_name || "", adminEmail: g.admin_email || "" }; |
| + } catch { |
| + return {}; |
| + } |
| +}); |
| +// The tour lives in your DM with Eleven: when "Show me around" is tapped elsewhere, |
| +// find-or-create that DM, open it, and hand the tour a sink bound to it so the rest |
| +// renders there. createThread dedupes DMs server-side, so this never spawns a |
| +// second one. Null if there's no Eleven to DM → the tour just stays put. |
| +setElevenTour(async () => { |
| + const eleven = roster.find((x) => x.is_agent && x.public_key && /eleven/i.test(x.label || "")); |
| + if (!eleven) return null; |
| + const dm = existingDM(eleven.user_id); |
| + const id = dm ? dm.id : await createThread([eleven.user_id], { group: false }); |
| + if (!id) return null; |
| + if (!dm) { |
| + await loadThreads(); |
| + renderThreadList(); |
| + } |
| + await openThread(id); |
| + // notice: append a private tour step here. ask: send a real chat message as the |
| + // user, so Eleven's AI actually replies (the tour's "Tell me about Eleven" chip). |
| + return { |
| + notice: (spec) => appendNotice(id, spec), |
| + ask: (text) => sendChatText(id, text), |
| + }; |
| +}); |
| + |
| +// Send a normal chat message (name + text) into a thread, as if typed. Same |
| +// envelope + encryption as the composer and the app-event transport; used by the |
| +// tour to ask Eleven a question on the member's behalf. |
| +async function sendChatText(threadId, text) { |
| + const key = threadKeys[threadId]; |
| + if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| + const payload = { name: session.name, text, ts: Date.now() }; |
| + if (myUserId) payload.uid = myUserId; |
| + const blob = await encrypt(key, payload); |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| + return true; |
| +} |
| // createAppThread makes a personal app's single-member thread: a `self` thread |
| // whose name_blob is the ENCRYPTED app id, wrapped only to us. The server sees |
diff --git a/web/static/style.css b/web/static/style.css| index 452b810..432e30f 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -798,6 +798,14 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| max-width: 90%; padding: 10px 12px; border-radius: 14px; background: var(--bubble-them); |
| } |
| .local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; } |
| +.local-notice-body code { |
| + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| + font-size: 0.92em; |
| + background: color-mix(in srgb, var(--ink) 8%, transparent); |
| + padding: 1px 5px; |
| + border-radius: 5px; |
| +} |
| +.local-notice-body a { color: var(--accent); } |
| .local-notice .chip-row { margin-top: 8px; } |
| /* A confirmation is loud only for a while: after ~30s it "blinds up" (its height |
| animates down — see collapseNotice) to this compact one-line record |
diff --git a/web/static/ui.js b/web/static/ui.js| index 268f952..b6d726f 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -172,6 +172,7 @@ export const CHIP_TYPES = { |
| set_appearance: { param: "appearance", valid: (v) => APPEARANCES.includes(v) }, |
| open_theme_picker: {}, |
| open_profile: {}, |
| + open_tutorial: {}, // starts Eleven's guided tour (e.g. the welcome DM's "Take the tour") |
| }; |
| // sanitizeActions coerces an untrusted payload `actions` array to renderable |