From 76805a9a77686114fdd2fdd7b4df1578ae7c46d0 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:19:06 +0300 Subject: [PATCH 01/23] build(cli): bundle To Do App demo template + embed it in the binary Adds packages/server/assets/demo/to-do-app (a tiny, dependency-free working to-do app) and embeds it into the single-file binary via a new demo-assets manifest, staged on boot to THINKRAIL_DEMO_DIR (mirrors the web/skills/runtime asset pipeline). No consumer yet. --- apps/cli/SPEC.md | 13 ++- apps/cli/scripts/build-binary.ts | 16 ++++ apps/cli/src/compiled-entry.ts | 3 + apps/cli/src/demo-assets.generated.d.ts | 16 ++++ biome.json | 8 +- .../server/assets/demo/to-do-app/README.md | 23 +++++ .../server/assets/demo/to-do-app/index.html | 28 ++++++ .../server/assets/demo/to-do-app/src/app.js | 67 ++++++++++++++ .../assets/demo/to-do-app/src/storage.js | 15 ++++ .../server/assets/demo/to-do-app/styles.css | 89 +++++++++++++++++++ 10 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 apps/cli/src/demo-assets.generated.d.ts create mode 100644 packages/server/assets/demo/to-do-app/README.md create mode 100644 packages/server/assets/demo/to-do-app/index.html create mode 100644 packages/server/assets/demo/to-do-app/src/app.js create mode 100644 packages/server/assets/demo/to-do-app/src/storage.js create mode 100644 packages/server/assets/demo/to-do-app/styles.css diff --git a/apps/cli/SPEC.md b/apps/cli/SPEC.md index c54d55c4c..079e4b506 100644 --- a/apps/cli/SPEC.md +++ b/apps/cli/SPEC.md @@ -161,9 +161,10 @@ entrypoint honors it (including `packages/server/src/dev.ts`, which parses no ar platform — via `bun build --compile`. Bun bundles the host *and* transparently embeds the `bun-pty` native lib; the extra steps are the **web UI** (a directory the host normally serves), the **bundled pi extensions** (which the server path-loads out of `node_modules` in dev — impossible inside a binary), -and `trash`'s **native helper sidecars** (which macOS/Windows must execute from real filesystem paths): +`trash`'s **native helper sidecars** (which macOS/Windows must execute from real filesystem paths), and +the **bundled demo project templates** (which the server copies out of `packages/server/assets` in dev): -- `scripts/build-binary.ts` writes three **transient** generated modules, runs +- `scripts/build-binary.ts` writes four **transient** generated modules, runs `bun build --compile --no-compile-autoload-bunfig --target=` on `src/compiled-entry.ts`, then deletes them (so the artifact cannot execute a project-local `bunfig.toml` preload before ThinkRail boots, and the working tree + `tsc` stay clean); each generated @@ -181,12 +182,16 @@ and `trash`'s **native helper sidecars** (which macOS/Windows must execute from `@earendil-works/pi-coding-agent`. - `src/runtime-assets.generated.ts` — embeds `trash`'s `macos-trash` and `windows-trash.exe` helper binaries, resolved from the server package's dependency context, as a content-hashed manifest. + - `src/demo-assets.generated.ts` — enumerates `packages/server/assets/demo` (the bundled demo project + templates, e.g. `to-do-app/…`): a Bun file-attribute import per file + a `{ route, data }[]` manifest + + a content-hash version, embedded like web assets. - `src/compiled-entry.ts` is the binary's entry: on startup it stages the embedded web + skills + - runtime-helper files to per-build cache dirs (`$XDG_CACHE_HOME`/`~/.cache`/temp; files written straight into the versioned dir, + runtime-helper + demo-template files to per-build cache dirs (`$XDG_CACHE_HOME`/`~/.cache`/temp; files written straight into the versioned dir, then a sibling `.complete` marker written **last** — readiness is gated on the marker, so a killed first run leaves an incomplete cache that's re-extracted next launch. **No stage-then-rename**: Bun's `renameSync` of a fresh non-empty dir `EPERM`s on Windows, so the marker replaces the directory-rename - publish), makes the macOS helper executable, sets `THINKRAIL_STATIC_DIR`, then **awaits** the server's + publish), makes the macOS helper executable, sets `THINKRAIL_STATIC_DIR` and `THINKRAIL_DEMO_DIR` (the + staged demo-template root the server materializes lazily from), then **awaits** the server's **`registerBundledRuntime`** seam — which injects the factories + staged skills dir + real trash-helper paths **and** performs pi's binary-only registrations (the statically-bundled OAuth flows + the Bedrock provider module, replacing pi's binary-hostile dynamic diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 708d7e087..40a7fdff5 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -20,6 +20,8 @@ const webDist = join(repoRoot, "apps", "web", "dist"); const webGeneratedPath = join(cliDir, "src", "web-assets.generated.ts"); const extGeneratedPath = join(cliDir, "src", "bundled-extensions.generated.ts"); const runtimeGeneratedPath = join(cliDir, "src", "runtime-assets.generated.ts"); +const demoGeneratedPath = join(cliDir, "src", "demo-assets.generated.ts"); +const demoDir = join(repoRoot, "packages", "server", "assets", "demo"); const entryPath = join(cliDir, "src", "compiled-entry.ts"); const outDir = join(cliDir, "dist"); const serverRequire = createRequire(join(repoRoot, "packages", "server", "package.json")); @@ -105,6 +107,18 @@ function generateRuntimeManifest(): void { ); } +function generateDemoManifest(): void { + if (!existsSync(demoDir)) throw new Error(`demo assets not found at ${demoDir}`); + const files = listFiles(demoDir) + .sort() + .map((file) => ({ root: demoDir, file })); + const { imports, entries, version } = embedFiles(files, "d"); + writeFileSync( + demoGeneratedPath, + `// GENERATED by scripts/build-binary.ts — do not edit. Embeds the bundled demo project templates.\n${imports.join("\n")}\n\nexport const demoAssetsVersion = ${JSON.stringify(version)};\nexport const embeddedDemoAssets = [\n${entries.join("\n")}\n];\n`, + ); +} + const target = process.argv.find((a) => a.startsWith("--target="))?.slice("--target=".length); const outFile = join(outDir, binaryArtifactName(target)); @@ -112,6 +126,7 @@ mkdirSync(outDir, { recursive: true }); generateWebManifest(); generateBundledExtensions(); generateRuntimeManifest(); +generateDemoManifest(); try { const proc = Bun.spawnSync( [ @@ -131,5 +146,6 @@ try { rmSync(webGeneratedPath, { force: true }); rmSync(extGeneratedPath, { force: true }); rmSync(runtimeGeneratedPath, { force: true }); + rmSync(demoGeneratedPath, { force: true }); } console.log(`\nBuilt single-file binary: ${outFile}`); diff --git a/apps/cli/src/compiled-entry.ts b/apps/cli/src/compiled-entry.ts index 6b551490b..9efd9c65b 100644 --- a/apps/cli/src/compiled-entry.ts +++ b/apps/cli/src/compiled-entry.ts @@ -8,6 +8,7 @@ import { bundledSkillsVersion, embeddedSkillFiles, } from "./bundled-extensions.generated"; +import { demoAssetsVersion, embeddedDemoAssets } from "./demo-assets.generated"; import { stagingRoot } from "./paths"; import { embeddedRuntimeAssets, runtimeAssetsVersion } from "./runtime-assets.generated"; import { embeddedWebAssets, webAssetsVersion } from "./web-assets.generated"; @@ -35,6 +36,8 @@ if (parseSubcommand(Bun.argv.slice(2)) === undefined) { const staticDir = await stage("web", webAssetsVersion, embeddedWebAssets); const skillsDir = await stage("skills", bundledSkillsVersion, embeddedSkillFiles); const runtimeDir = await stage("runtime", runtimeAssetsVersion, embeddedRuntimeAssets); + const demoDir = await stage("demo", demoAssetsVersion, embeddedDemoAssets); + process.env.THINKRAIL_DEMO_DIR ??= demoDir; const macosTrash = join(runtimeDir, "macos-trash"); const windowsTrash = join(runtimeDir, "windows-trash.exe"); if (process.platform !== "win32") chmodSync(macosTrash, 0o755); diff --git a/apps/cli/src/demo-assets.generated.d.ts b/apps/cli/src/demo-assets.generated.d.ts new file mode 100644 index 000000000..c4618e9d8 --- /dev/null +++ b/apps/cli/src/demo-assets.generated.d.ts @@ -0,0 +1,16 @@ +// Type contract for the build-time-generated demo-assets module (`src/demo-assets.generated.ts`), which +// `bun run build:binary` writes just before `bun build --compile` and deletes afterward. This committed +// declaration keeps `compiled-entry.ts` typecheckable while the generated source is absent. + +export interface EmbeddedDemoAsset { + /** Path relative to the staged demo root, posix-style — e.g. `to-do-app/index.html`. */ + route: string; + /** Embedded-file path (a Bun `import … with { type: "file" }`), readable at runtime via `Bun.file`. */ + data: string; +} + +/** Every file under the bundled demo project templates, embedded into the single-file binary. */ +export declare const embeddedDemoAssets: EmbeddedDemoAsset[]; + +/** Content hash of the embedded demo templates — keys the on-disk staging dir so a new build re-extracts. */ +export declare const demoAssetsVersion: string; diff --git a/biome.json b/biome.json index 1af30c8ad..c4743a775 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": true, - "includes": ["**", "!designs", "!**/.claude", "!apps/web/src/styles/generated"] + "includes": [ + "**", + "!designs", + "!**/.claude", + "!apps/web/src/styles/generated", + "!packages/server/assets" + ] }, "formatter": { "enabled": true, diff --git a/packages/server/assets/demo/to-do-app/README.md b/packages/server/assets/demo/to-do-app/README.md new file mode 100644 index 000000000..52eb5ba2e --- /dev/null +++ b/packages/server/assets/demo/to-do-app/README.md @@ -0,0 +1,23 @@ +# To Do App + +A tiny, dependency-free to-do list you open straight in the browser — the bundled ThinkRail demo +project. It is a real git repository once opened, so you can cut isolated workspaces from it and pair +with the agent on real changes. + +## Run it + +Open `index.html` in a browser. Tasks persist in `localStorage`. + +## Layout + +- `index.html` — the page shell. +- `styles.css` — presentation only. +- `src/storage.js` — load/save the task list (localStorage). +- `src/app.js` — rendering + add/toggle/delete wiring. + +## Try an onboarding task + +Cut a workspace and ask the agent to: + +- **Add search functionality** — filter the visible tasks by a text query. +- **Add a filter for completed tasks** — show all / active / completed. diff --git a/packages/server/assets/demo/to-do-app/index.html b/packages/server/assets/demo/to-do-app/index.html new file mode 100644 index 000000000..8806c77dd --- /dev/null +++ b/packages/server/assets/demo/to-do-app/index.html @@ -0,0 +1,28 @@ + + + + + + To Do App + + + +
+

To Do

+
+ + +
+
    + +
    + + + diff --git a/packages/server/assets/demo/to-do-app/src/app.js b/packages/server/assets/demo/to-do-app/src/app.js new file mode 100644 index 000000000..46e0d78db --- /dev/null +++ b/packages/server/assets/demo/to-do-app/src/app.js @@ -0,0 +1,67 @@ +import { loadTasks, saveTasks } from "./storage.js"; + +const listEl = document.getElementById("task-list"); +const emptyEl = document.getElementById("empty"); +const formEl = document.getElementById("new-task"); +const inputEl = document.getElementById("new-task-input"); + +let tasks = loadTasks(); + +function persist() { + saveTasks(tasks); + render(); +} + +function addTask(title) { + const trimmed = title.trim(); + if (!trimmed) return; + tasks = [...tasks, { id: crypto.randomUUID(), title: trimmed, done: false }]; + persist(); +} + +function toggleTask(id) { + tasks = tasks.map((task) => (task.id === id ? { ...task, done: !task.done } : task)); + persist(); +} + +function deleteTask(id) { + tasks = tasks.filter((task) => task.id !== id); + persist(); +} + +function render() { + listEl.replaceChildren(); + for (const task of tasks) { + const item = document.createElement("li"); + item.className = task.done ? "task task--done" : "task"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = task.done; + checkbox.addEventListener("change", () => toggleTask(task.id)); + + const title = document.createElement("span"); + title.className = "task__title"; + title.textContent = task.title; + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "task__delete"; + remove.textContent = "✕"; + remove.setAttribute("aria-label", `Delete ${task.title}`); + remove.addEventListener("click", () => deleteTask(task.id)); + + item.append(checkbox, title, remove); + listEl.append(item); + } + emptyEl.hidden = tasks.length > 0; +} + +formEl.addEventListener("submit", (event) => { + event.preventDefault(); + addTask(inputEl.value); + inputEl.value = ""; + inputEl.focus(); +}); + +render(); diff --git a/packages/server/assets/demo/to-do-app/src/storage.js b/packages/server/assets/demo/to-do-app/src/storage.js new file mode 100644 index 000000000..042dc28a1 --- /dev/null +++ b/packages/server/assets/demo/to-do-app/src/storage.js @@ -0,0 +1,15 @@ +const STORAGE_KEY = "thinkrail-todo-app"; + +export function loadTasks() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function saveTasks(tasks) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); +} diff --git a/packages/server/assets/demo/to-do-app/styles.css b/packages/server/assets/demo/to-do-app/styles.css new file mode 100644 index 000000000..1865ffac0 --- /dev/null +++ b/packages/server/assets/demo/to-do-app/styles.css @@ -0,0 +1,89 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; + display: flex; + justify-content: center; + background: Canvas; + color: CanvasText; +} + +.app { + width: 100%; + max-width: 32rem; + padding: 2rem 1rem; +} + +h1 { + margin: 0 0 1rem; + font-size: 1.75rem; +} + +.new-task { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.new-task__input { + flex: 1; + padding: 0.5rem 0.75rem; + font: inherit; + border: 1px solid GrayText; + border-radius: 0.5rem; + background: Field; + color: FieldText; +} + +.new-task__add { + padding: 0.5rem 1rem; + font: inherit; + border: 0; + border-radius: 0.5rem; + background: Highlight; + color: HighlightText; + cursor: pointer; +} + +.task-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.task { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.25rem; + border-bottom: 1px solid ButtonBorder; +} + +.task__title { + flex: 1; +} + +.task--done .task__title { + text-decoration: line-through; + opacity: 0.6; +} + +.task__delete { + border: 0; + background: transparent; + color: GrayText; + cursor: pointer; + font-size: 1rem; +} + +.empty { + color: GrayText; + text-align: center; + margin-top: 2rem; +} From b2e346c337e5d8efb8f1857a50886fac4270d2c9 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:21:59 +0300 Subject: [PATCH 02/23] =?UTF-8?q?feat(server):=20demo=20module=20=E2=80=94?= =?UTF-8?q?=20lazily=20materialize=20the=20To=20Do=20App=20demo=20as=20a?= =?UTF-8?q?=20real=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds packages/server/src/demo: ensureDemoProject() copies the bundled template into dataDir/demo/to-do-app (never mutating the source) and hands off to the existing initProject (git init + open); idempotent. removeDemoFiles() is the file half of reset. Adds deleteProject() to the projects module for the record half. Specs + unit tests included. --- packages/server/SPEC.md | 4 +- packages/server/src/demo/SPEC.md | 43 ++++++++++++++++++ packages/server/src/demo/demo.test.ts | 57 ++++++++++++++++++++++++ packages/server/src/demo/demo.ts | 30 +++++++++++++ packages/server/src/demo/index.ts | 1 + packages/server/src/projects/SPEC.md | 2 +- packages/server/src/projects/projects.ts | 9 ++++ 7 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/demo/SPEC.md create mode 100644 packages/server/src/demo/demo.test.ts create mode 100644 packages/server/src/demo/demo.ts create mode 100644 packages/server/src/demo/index.ts diff --git a/packages/server/SPEC.md b/packages/server/SPEC.md index 23560b62f..2a1718a7a 100644 --- a/packages/server/SPEC.md +++ b/packages/server/SPEC.md @@ -54,6 +54,7 @@ internals**. The edges between them are owned here (see the dependency graph), n | `settings` | server-synced app config, including layout preset/default/side-limit settings | [settings/SPEC.md](src/settings/SPEC.md) | | `layout` | validated, revisioned, persisted per-workspace workbench snapshots | [layout/SPEC.md](src/layout/SPEC.md) | | `projects` | stable known-repo registry: open/recent views + lossless close/reopen (validate, dedupe, slug) | [projects/SPEC.md](src/projects/SPEC.md) | +| `demo` | materialize the bundled To Do App demo as a real user-owned repo (lazy copy + `git init`) | [demo/SPEC.md](src/demo/SPEC.md) | | `workspaces` | workspaces = `git worktree`s on their own branch | [workspaces/SPEC.md](src/workspaces/SPEC.md) | | `git` | the `git(cwd, args)` runner + worktree status/diff vs base + branch list | [git/SPEC.md](src/git/SPEC.md) | | `github` | read-only local `gh` auth status (shell-out) for the New-Workspace surface | [github/SPEC.md](src/github/SPEC.md) | @@ -80,10 +81,11 @@ the host from env via `bootHost` for dev/e2e. `host` is the **only composition root** — it wires each feature's handlers into the WS registry. -- `host` → `projects`, `workspaces`, `git`, `github`, `branch-review`, `fs`, `spec`, `todos`, `reviews`, `watch`, `terminal`, `dialog`, `editors`, `agent`, `auth`, `assist`, `settings`, `layout`, `history`, `templates`, `analytics`, `persistence` (`dataDir`, for the crash report) +- `host` → `projects`, `demo`, `workspaces`, `git`, `github`, `branch-review`, `fs`, `spec`, `todos`, `reviews`, `watch`, `terminal`, `dialog`, `editors`, `agent`, `auth`, `assist`, `settings`, `layout`, `history`, `templates`, `analytics`, `persistence` (`dataDir`, for the crash report) - `workspaces` → `projects`, `git`, `persistence` - `branch-review` → `git` - `projects` → `git` (shared runner), `persistence` +- `demo` → `projects` (`initProject`), `persistence` (`dataDir`) - `git`, `fs`, `spec`, `watch`, `terminal`, `settings`, `layout`, `analytics` → `persistence` (`spec` also → `pi-spec-graph/core`, external; `analytics` also → the pi-ai built-in provider/model catalog + `posthog-node`, external — the identity-bucketing vocabulary and the delivery SDK) - `todos` → `workspaces` (worktree path lookup) + `pi-todos/core` (external, value-imported, pi-free) - `reviews` → `workspaces` (worktree path lookup), `persistence` (data dir), `git` (the review's baseSha diff --git a/packages/server/src/demo/SPEC.md b/packages/server/src/demo/SPEC.md new file mode 100644 index 000000000..aadc1dc4d --- /dev/null +++ b/packages/server/src/demo/SPEC.md @@ -0,0 +1,43 @@ +--- +id: submodule-server-demo +type: submodule-design +status: active +title: demo — bundled demo project +parent: module-server +depends-on: [module-contracts, submodule-server-projects] +tags: [v1] +--- + +## Responsibility + +Materialize the bundled **To Do App** demo as a real, user-owned git repository so first-run onboarding +needs no repo of the user's own. The demo is a **normal Project** the moment it exists — it participates +in the ordinary Project → Workspace → git-worktree flow with no separate/fake project model — so this +module only owns the *materialization* (copy template + `git init`) and *file cleanup*; opening, +workspaces, sessions, and worktrees are the existing modules' jobs, unchanged. + +**Lazy, never eager.** The copy happens only when the user explicitly starts the demo (the `demo.ensure` +wire door), never at host startup. + +## Boundary + +- **Owns:** + - `demoProjectPath()` — the fixed user-local location `dataDir()/demo/to-do-app` (honours + `THINKRAIL_DATA_DIR`). Deliberately **not** under `dataDir()/worktrees` — that tree is reserved for + managed worktree dirs keyed by project slug ([[submodule-server-workspaces]]). + - `ensureDemoProject()` — idempotent: when the target is absent, copy the bundled template into it + (never mutating the bundled source), then hand off to `initProject` (git init + initial commit, or a + short-circuit `openProject` when the repo already exists). Returns the `Project`. A second call + re-opens the existing record rather than re-initialising. + - `removeDemoFiles()` — `rm -rf` the user-local copy. The *domain* half of a reset (archiving the + demo's workspaces + dropping the project record via `deleteProject`) is orchestrated by `host`, which + can reach the per-workspace teardown seams this module must not (terminals, spec index, reviews, + watch, layout). + - Template source resolution: `THINKRAIL_DEMO_DIR` (the staged root the binary sets — see the CLI + SPEC) when present, else the in-repo dev path `packages/server/assets/demo`. Both point at the parent + that contains `to-do-app/`. +- **Public surface (barrel):** `demoProjectPath`, `ensureDemoProject`, `removeDemoFiles`, `DEMO_APP_DIR`. +- **Allowed deps:** `projects` (`initProject`); `persistence` (`dataDir`); `contracts` (`Project`); + Node/Bun. +- **Forbidden:** `host`; sibling features other than `projects` (no `workspaces`/`agent`/`terminal` + reach — reset orchestration lives in `host`); mutating the bundled template under `packages/server/assets`. diff --git a/packages/server/src/demo/demo.test.ts b/packages/server/src/demo/demo.test.ts new file mode 100644 index 000000000..70738df33 --- /dev/null +++ b/packages/server/src/demo/demo.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listProjects, setProjectPublisher } from "../projects"; +import { DEMO_APP_DIR, demoProjectPath, ensureDemoProject, removeDemoFiles } from "./demo"; + +function gitOut(cwd: string, ...args: string[]): string { + const r = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" }); + return new TextDecoder().decode(r.stdout).trim(); +} + +let dataDir: string; +const savedDataDir = process.env.THINKRAIL_DATA_DIR; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "trpi-demo-test-")); + process.env.THINKRAIL_DATA_DIR = dataDir; +}); + +afterEach(() => { + setProjectPublisher(null); + rmSync(dataDir, { recursive: true, force: true }); + if (savedDataDir === undefined) delete process.env.THINKRAIL_DATA_DIR; + else process.env.THINKRAIL_DATA_DIR = savedDataDir; +}); + +test("demoProjectPath is dataDir/demo/to-do-app, never under worktrees", () => { + expect(demoProjectPath()).toBe(join(dataDir, "demo", DEMO_APP_DIR)); +}); + +test("ensureDemoProject copies the template, inits a real repo, and opens it", () => { + const project = ensureDemoProject(); + + expect(project.path).toBe(realpathSync(demoProjectPath())); + expect(existsSync(join(demoProjectPath(), "index.html"))).toBe(true); + expect(existsSync(join(demoProjectPath(), "src", "app.js"))).toBe(true); + expect(gitOut(demoProjectPath(), "rev-parse", "HEAD")).not.toBe(""); + expect(gitOut(demoProjectPath(), "ls-tree", "-r", "HEAD", "--name-only")).toContain("index.html"); + expect(listProjects().map((p) => p.id)).toEqual([project.id]); +}); + +test("ensureDemoProject is idempotent — a second call reopens the same project", () => { + const first = ensureDemoProject(); + const second = ensureDemoProject(); + + expect(second.id).toBe(first.id); + expect(listProjects()).toHaveLength(1); +}); + +test("removeDemoFiles deletes the user-local copy", () => { + ensureDemoProject(); + expect(existsSync(demoProjectPath())).toBe(true); + + removeDemoFiles(); + expect(existsSync(demoProjectPath())).toBe(false); +}); diff --git a/packages/server/src/demo/demo.ts b/packages/server/src/demo/demo.ts new file mode 100644 index 000000000..df81829eb --- /dev/null +++ b/packages/server/src/demo/demo.ts @@ -0,0 +1,30 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { Project } from "@thinkrail/contracts"; +import { dataDir } from "../persistence"; +import { initProject } from "../projects"; + +export const DEMO_APP_DIR = "to-do-app"; + +function templateRoot(): string { + return process.env.THINKRAIL_DEMO_DIR ?? resolve(import.meta.dir, "../../assets/demo"); +} + +export function demoProjectPath(): string { + return join(dataDir(), "demo", DEMO_APP_DIR); +} + +export function ensureDemoProject(): Project { + const target = demoProjectPath(); + if (!existsSync(target)) { + const source = join(templateRoot(), DEMO_APP_DIR); + if (!existsSync(source)) throw new Error(`Demo template not found: ${source}`); + mkdirSync(join(dataDir(), "demo"), { recursive: true }); + cpSync(source, target, { recursive: true }); + } + return initProject(target); +} + +export function removeDemoFiles(): void { + rmSync(demoProjectPath(), { recursive: true, force: true }); +} diff --git a/packages/server/src/demo/index.ts b/packages/server/src/demo/index.ts new file mode 100644 index 000000000..d0845380e --- /dev/null +++ b/packages/server/src/demo/index.ts @@ -0,0 +1 @@ +export * from "./demo"; diff --git a/packages/server/src/projects/SPEC.md b/packages/server/src/projects/SPEC.md index 83b43a22c..6d9124f34 100644 --- a/packages/server/src/projects/SPEC.md +++ b/packages/server/src/projects/SPEC.md @@ -41,7 +41,7 @@ bootstrap it into one so it can be opened. — `host` answers the lazy `project.hasSpecs` query via `spec.projectHasSpecs`, keeping this module free of any spec dependency.) - **Public surface (barrel):** `openProject`, `listProjects`, `listRecentProjects`, `closeProject`, - `getProjects`, `setProjectPublisher`, `inspectProjectPath`, `initProject`. + `deleteProject`, `getProjects`, `setProjectPublisher`, `inspectProjectPath`, `initProject`. - **Allowed deps:** `persistence`; the `git` sub-module (shared `git()` runner, bound to live `env` for config overrides); `contracts` (`Project`, `ProjectPathStatus`); Node/Bun. - **Forbidden:** `host`; sibling features other than `git` (`workspaces` depends on `projects`, never the diff --git a/packages/server/src/projects/projects.ts b/packages/server/src/projects/projects.ts index 4b647e0b6..207705b1d 100644 --- a/packages/server/src/projects/projects.ts +++ b/packages/server/src/projects/projects.ts @@ -105,6 +105,15 @@ export function listRecentProjects(): Project[] { return newestFirst(getProjects()); } +export function deleteProject(id: string): Project | null { + const projects = getProjects(); + const index = projects.findIndex((candidate) => candidate.id === id); + if (index === -1) return null; + const [removed] = projects.splice(index, 1); + saveProjects(projects); + return removed ?? null; +} + export function closeProject(id: string): Project { const projects = getProjects(); const project = projects.find((candidate) => candidate.id === id); From 5383719bd9ed3f5fc40b7ec4a308d11cf971942d Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:28:31 +0300 Subject: [PATCH 03/23] feat(server): wire demo.ensure / demo.reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.ensure materializes + opens the bundled To Do App demo (idempotent). demo.reset archives the demo's workspaces (reusing the shared per-workspace teardown, now factored out of workspace.remove), drops the project record, and deletes the user-local copy — so onboarding can be restarted safely. No project/workspace persistence, session lifecycle, or tab-close semantics change. --- packages/contracts/src/wsProtocol.ts | 4 +++ packages/server/src/host/handlers.ts | 46 ++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/contracts/src/wsProtocol.ts b/packages/contracts/src/wsProtocol.ts index 40eedf59b..7e2fbe52b 100644 --- a/packages/contracts/src/wsProtocol.ts +++ b/packages/contracts/src/wsProtocol.ts @@ -114,6 +114,8 @@ export const WS_METHODS = { projectAliasSkills: "project.aliasSkills", projectSetGroupEnabled: "project.setGroupEnabled", projectSkills: "project.skills", + demoEnsure: "demo.ensure", + demoReset: "demo.reset", workspaceCreate: "workspace.create", workspaceListExisting: "workspace.listExisting", workspaceOpenExisting: "workspace.openExisting", @@ -278,6 +280,8 @@ export interface WsMethodMap { result: Project; }; "project.skills": { params: { projectId: string }; result: SkillCatalogEntry[] }; + "demo.ensure": { params: Record; result: Project }; + "demo.reset": { params: Record; result: Ack }; "workspace.create": { params: { projectId: string; name?: string; baseRef?: string }; result: Workspace; diff --git a/packages/server/src/host/handlers.ts b/packages/server/src/host/handlers.ts index e7e0c6b99..7e4948f37 100644 --- a/packages/server/src/host/handlers.ts +++ b/packages/server/src/host/handlers.ts @@ -66,10 +66,18 @@ import { updateJbcentral, } from "../auth"; import { findOpenBranchReview } from "../branch-review"; +import { demoProjectPath, ensureDemoProject, removeDemoFiles } from "../demo"; import { selectDirectory } from "../dialog"; import { listAvailableEditors, openEditor, revealInFileManager } from "../editors"; import { readDir, readFile } from "../fs"; -import { gitDiffFile, gitStatus, listBranches, listCommits, prefetchBranch } from "../git"; +import { + canonicalPath, + gitDiffFile, + gitStatus, + listBranches, + listCommits, + prefetchBranch, +} from "../git"; import { githubAuthStatus, githubRefresh } from "../github"; import { clampLimit, getHistoryIndex } from "../history"; import { @@ -81,6 +89,8 @@ import { import { acknowledgeProjectSkills, closeProject, + deleteProject, + getProjects, initProject, inspectProjectPath, listProjects, @@ -166,6 +176,15 @@ async function archiveTeardown(ws: Workspace): Promise { } } +function teardownWorkspace(ws: Workspace): Promise { + removeWorkspaceLayout(ws.id); + evictSpecIndex(ws.id); + removeWorkspaceReviews(ws.id); + stopWatch(ws.id); + closeWorkspaceTerminals(ws.id); + return archiveTeardown(ws); +} + function trackSend(mode: SendMode, text: string): void { if (isControlMessage(text)) return; track({ name: "message_sent", params: { mode } }); @@ -250,6 +269,22 @@ const handlers: Record = { closeProject((params as { id: string }).id); return { ok: true } as const; }, + "demo.ensure": () => ensureDemoProject(), + "demo.reset": async () => { + const target = canonicalPath(demoProjectPath()); + const project = getProjects().find((p) => canonicalPath(p.path) === target); + if (project) { + await Promise.all( + listWorkspaceRecords(project.id).map((record) => { + const ws = forgetWorkspace(record.id); + return ws ? teardownWorkspace(ws) : Promise.resolve(); + }), + ); + deleteProject(project.id); + } + removeDemoFiles(); + return { ok: true } as const; + }, "project.setTrust": async (params) => { const p = params as { id: string; trusted: boolean }; const project = listProjects().find((candidate) => candidate.id === p.id); @@ -278,14 +313,7 @@ const handlers: Record = { "workspace.remove": (params) => { const id = (params as { id: string }).id; const ws = forgetWorkspace(id); - if (ws) { - removeWorkspaceLayout(ws.id); - evictSpecIndex(ws.id); - removeWorkspaceReviews(ws.id); - stopWatch(ws.id); - closeWorkspaceTerminals(ws.id); - void archiveTeardown(ws); - } + if (ws) void teardownWorkspace(ws); return { ok: true } as const; }, "workspace.diffStats": (params) => workspaceDiffStats((params as { id: string }).id), From d53fb9ceaabcf0945ed5ded49598365af6548abf Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:46:37 +0300 Subject: [PATCH 04/23] feat(server): ship a small SPEC.md in the To Do App demo template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled demo now carries a goal-and-requirements spec node, so it is a specced project from first open: project.hasSpecs is true, the Specs side-tool has content, and Welcome leads with Start building — the natural onboarding path. Demo SPEC notes the reset-as-replay door. --- packages/server/assets/demo/to-do-app/SPEC.md | 34 +++++++++++++++++++ packages/server/src/demo/SPEC.md | 10 ++++++ packages/server/src/demo/demo.test.ts | 4 ++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/server/assets/demo/to-do-app/SPEC.md diff --git a/packages/server/assets/demo/to-do-app/SPEC.md b/packages/server/assets/demo/to-do-app/SPEC.md new file mode 100644 index 000000000..5c3f8be51 --- /dev/null +++ b/packages/server/assets/demo/to-do-app/SPEC.md @@ -0,0 +1,34 @@ +--- +id: to-do-app +type: goal-and-requirements +status: active +title: To Do App +tags: [demo] +--- + +## Goal + +A tiny, dependency-free to-do list that runs straight in the browser — the bundled ThinkRail demo +project. It exists so a new user can try the ThinkRail loop (create a workspace, pair with the agent, +review changes) on a real git repository without bringing one of their own. + +## Scope + +- Add a task from a text input. +- Toggle a task complete / active. +- Delete a task. +- Persist the list across reloads (browser `localStorage`). + +## Structure + +- `index.html` — the page shell and the new-task form. +- `styles.css` — presentation only (system color tokens, no framework). +- `src/storage.js` — load/save the task list. +- `src/app.js` — rendering plus add / toggle / delete wiring. + +## Suggested next steps + +Good first tasks to pair with the agent on: + +- **Add search** — filter the visible tasks by a text query. +- **Filter by status** — show all / active / completed tasks. diff --git a/packages/server/src/demo/SPEC.md b/packages/server/src/demo/SPEC.md index aadc1dc4d..e3063ff23 100644 --- a/packages/server/src/demo/SPEC.md +++ b/packages/server/src/demo/SPEC.md @@ -19,6 +19,16 @@ workspaces, sessions, and worktrees are the existing modules' jobs, unchanged. **Lazy, never eager.** The copy happens only when the user explicitly starts the demo (the `demo.ensure` wire door), never at host startup. +**Ships its own spec.** The bundled template carries a small `SPEC.md` (a `goal-and-requirements` node +describing the To Do App), so the demo is a *specced* project from first open: `project.hasSpecs` is true, +the Specs side-tool has content, and the Welcome fork leads with "Start building" rather than the +spec-first "Set up project" — the natural path for the onboarding tour. + +**Reset is the onboarding replay door.** `demo.reset` (host-orchestrated, below) archives the demo's +workspaces, drops the project record, and deletes the user-local copy, returning the app to the empty +first-run state — the frontend "Reset demo" control that lets a user replay the onboarding tour is built +on it (see the web onboarding SPEC). + ## Boundary - **Owns:** diff --git a/packages/server/src/demo/demo.test.ts b/packages/server/src/demo/demo.test.ts index 70738df33..053ac6cca 100644 --- a/packages/server/src/demo/demo.test.ts +++ b/packages/server/src/demo/demo.test.ts @@ -36,7 +36,9 @@ test("ensureDemoProject copies the template, inits a real repo, and opens it", ( expect(existsSync(join(demoProjectPath(), "index.html"))).toBe(true); expect(existsSync(join(demoProjectPath(), "src", "app.js"))).toBe(true); expect(gitOut(demoProjectPath(), "rev-parse", "HEAD")).not.toBe(""); - expect(gitOut(demoProjectPath(), "ls-tree", "-r", "HEAD", "--name-only")).toContain("index.html"); + const tracked = gitOut(demoProjectPath(), "ls-tree", "-r", "HEAD", "--name-only"); + expect(tracked).toContain("index.html"); + expect(tracked).toContain("SPEC.md"); expect(listProjects().map((p) => p.id)).toEqual([project.id]); }); From f11ac13f72f7685e453d6a36dac15a16cc514e9f Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:50:34 +0300 Subject: [PATCH 05/23] feat(web): onboarding store slice + persistence Adds an onboarding state slice (flow/demoProjectId/dismissed) with start/dismiss/reset/hydrate actions, per-browser localStorage persistence (host-qualified key, mirrors projectExpansion), and derived selectors: the current step (0..3) is computed from real domain state (demo workspace count + whether each has a sent user turn), so it auto-advances and self-heals across reloads. Unit tests for the advance predicates. --- apps/web/src/main.tsx | 2 + apps/web/src/onboarding/persistence.ts | 40 +++++++++++ apps/web/src/store/appStore.ts | 28 ++++++++ apps/web/src/store/onboarding.test.ts | 95 ++++++++++++++++++++++++++ apps/web/src/store/selectors.ts | 61 ++++++++++++++++- 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/onboarding/persistence.ts create mode 100644 apps/web/src/store/onboarding.test.ts diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c9ab58830..b99be9af3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { initNavigation } from "./navigation"; +import { initOnboardingPersistence } from "./onboarding/persistence"; import { initProjectExpansionPersistence } from "./panels/projectExpansion"; import { Shell } from "./shell/Shell"; import { applyTheme, initializeBundledThemes, readThemeHint } from "./themes"; @@ -12,6 +13,7 @@ initializeBundledThemes(); applyTheme(readThemeHint()); initTransport(); initProjectExpansionPersistence(); +initOnboardingPersistence(); initNavigation(); const root = document.getElementById("root"); diff --git a/apps/web/src/onboarding/persistence.ts b/apps/web/src/onboarding/persistence.ts new file mode 100644 index 000000000..d52b9f7a9 --- /dev/null +++ b/apps/web/src/onboarding/persistence.ts @@ -0,0 +1,40 @@ +import { STORAGE_PREFIX } from "../constants/branding"; +import { NO_ONBOARDING, type OnboardingState, useAppStore } from "../store"; +import { getTransport } from "../transport"; + +function storageKey(): string { + return `${STORAGE_PREFIX}onboarding:${getTransport().httpBase()}`; +} + +export function readPersistedOnboarding(): OnboardingState { + try { + const raw = localStorage.getItem(storageKey()); + if (!raw) return NO_ONBOARDING; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return NO_ONBOARDING; + const value = parsed as Record; + return { + flow: value.flow === "demo" ? "demo" : null, + demoProjectId: typeof value.demoProjectId === "string" ? value.demoProjectId : null, + dismissed: value.dismissed === true, + }; + } catch { + return NO_ONBOARDING; + } +} + +function persistOnboarding(onboarding: OnboardingState): void { + try { + localStorage.setItem(storageKey(), JSON.stringify(onboarding)); + } catch {} +} + +export function initOnboardingPersistence(): void { + useAppStore.getState().hydrateOnboarding(readPersistedOnboarding()); + let previous = useAppStore.getState().onboarding; + useAppStore.subscribe((state) => { + if (state.onboarding === previous) return; + previous = state.onboarding; + persistOnboarding(previous); + }); +} diff --git a/apps/web/src/store/appStore.ts b/apps/web/src/store/appStore.ts index 2155dc21a..6cd9446a4 100644 --- a/apps/web/src/store/appStore.ts +++ b/apps/web/src/store/appStore.ts @@ -260,6 +260,20 @@ export interface ChatLocationRequest { navigation?: CenterNavigationStamp | null; } +export type OnboardingFlow = "demo"; + +export interface OnboardingState { + flow: OnboardingFlow | null; + demoProjectId: string | null; + dismissed: boolean; +} + +export const NO_ONBOARDING: OnboardingState = { + flow: null, + demoProjectId: null, + dismissed: false, +}; + export interface SessionRuntime { turns: ChatTurn[]; turnIdByMessageIndex?: (string | null)[]; @@ -645,6 +659,7 @@ interface AppState { terminalReplayKb: number; layoutSettings: LayoutSettings; toasts: Toast[]; + onboarding: OnboardingState; setStatus: (status: ConnectionStatus) => void; installWelcomeSnapshot: ( protocolVersion: number, @@ -812,6 +827,10 @@ interface AppState { applyReviewChanged: (payload: ReviewChangedPayload) => void; pushToast: (toast: Omit) => string; dismissToast: (id: string) => void; + startOnboarding: (demoProjectId: string) => void; + dismissOnboarding: () => void; + resetOnboarding: () => void; + hydrateOnboarding: (onboarding: OnboardingState) => void; } function sortProjects(projects: Project[]): Project[] { @@ -1303,6 +1322,7 @@ export const useAppStore = create((set, get) => ({ terminalReplayKb: DEFAULT_CONFIG.terminalReplayKb, layoutSettings: DEFAULT_CONFIG.layout, toasts: [], + onboarding: NO_ONBOARDING, setStatus: (status) => set((state) => ({ status, @@ -2759,6 +2779,14 @@ export const useAppStore = create((set, get) => ({ set((s) => s.toasts.some((t) => t.id === id) ? { toasts: s.toasts.filter((t) => t.id !== id) } : {}, ), + startOnboarding: (demoProjectId) => + set({ onboarding: { flow: "demo", demoProjectId, dismissed: false } }), + dismissOnboarding: () => + set((s) => + s.onboarding.dismissed ? {} : { onboarding: { ...s.onboarding, dismissed: true } }, + ), + resetOnboarding: () => set({ onboarding: NO_ONBOARDING }), + hydrateOnboarding: (onboarding) => set({ onboarding }), })); export const toast = { diff --git a/apps/web/src/store/onboarding.test.ts b/apps/web/src/store/onboarding.test.ts new file mode 100644 index 000000000..fa336e811 --- /dev/null +++ b/apps/web/src/store/onboarding.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; +import type { Workspace } from "@thinkrail/contracts"; +import { + type ChatTab, + EMPTY_RUNTIME, + NO_ONBOARDING, + type OnboardingState, + type SessionRuntime, +} from "./appStore"; +import { + selectAgentStarted, + selectDemoWorkspaces, + selectOnboardingActive, + selectOnboardingStep, +} from "./selectors"; + +const DEMO = "demo-project"; + +function ws(id: string, kind?: "default"): Workspace { + return { + id, + projectId: DEMO, + name: id, + branch: id, + worktreePath: `/wt/${id}`, + baseBranch: "main", + ...(kind ? { kind } : {}), + }; +} + +function chatTab(workspaceId: string, sessionId: string): ChatTab { + return { kind: "chat", id: `tab-${sessionId}`, workspaceId, name: "Chat", sessionId }; +} + +function withUserTurn(): SessionRuntime { + return { + ...EMPTY_RUNTIME, + turns: [{ kind: "user", id: "u1", message: { role: "user", content: "hi", timestamp: 0 } }], + }; +} + +const onboarding: OnboardingState = { flow: "demo", demoProjectId: DEMO, dismissed: false }; + +function baseState(workspaces: Workspace[]) { + return { + onboarding, + workspaces: { [DEMO]: workspaces }, + sessions: {} as Record, + tabsByWorkspace: {} as Record, + closedChatsByWorkspace: {}, + }; +} + +test("selectOnboardingActive: true only for an armed, undismissed demo flow", () => { + expect(selectOnboardingActive({ onboarding })).toBe(true); + expect(selectOnboardingActive({ onboarding: { ...onboarding, dismissed: true } })).toBe(false); + expect(selectOnboardingActive({ onboarding: NO_ONBOARDING })).toBe(false); +}); + +test("selectDemoWorkspaces: excludes the Default workspace", () => { + const list = selectDemoWorkspaces({ + onboarding, + workspaces: { [DEMO]: [ws("default", "default"), ws("a"), ws("b")] }, + }); + expect(list.map((w) => w.id)).toEqual(["a", "b"]); +}); + +test("step 0 until two non-Default workspaces exist", () => { + expect(selectOnboardingStep(baseState([ws("default", "default")]))).toBe(0); + expect(selectOnboardingStep(baseState([ws("default", "default"), ws("a")]))).toBe(0); + expect(selectOnboardingStep(baseState([ws("a"), ws("b")]))).toBe(1); +}); + +test("step 1 → 2 once the first workspace's agent has a user turn", () => { + const state = baseState([ws("a"), ws("b")]); + state.tabsByWorkspace = { a: [chatTab("a", "s-a")] }; + state.sessions = { "s-a": withUserTurn() }; + expect(selectAgentStarted(state, "a")).toBe(true); + expect(selectAgentStarted(state, "b")).toBe(false); + expect(selectOnboardingStep(state)).toBe(2); +}); + +test("step 3 (done) once both workspaces have started an agent", () => { + const state = baseState([ws("a"), ws("b")]); + state.tabsByWorkspace = { a: [chatTab("a", "s-a")], b: [chatTab("b", "s-b")] }; + state.sessions = { "s-a": withUserTurn(), "s-b": withUserTurn() }; + expect(selectOnboardingStep(state)).toBe(3); +}); + +test("a chat tab with no user turn does not count as started", () => { + const state = baseState([ws("a"), ws("b")]); + state.tabsByWorkspace = { a: [chatTab("a", "s-a")] }; + state.sessions = { "s-a": EMPTY_RUNTIME }; + expect(selectOnboardingStep(state)).toBe(1); +}); diff --git a/apps/web/src/store/selectors.ts b/apps/web/src/store/selectors.ts index fca2dcefc..ae847e365 100644 --- a/apps/web/src/store/selectors.ts +++ b/apps/web/src/store/selectors.ts @@ -15,7 +15,14 @@ import { normalizePath, readLayoutSelection, } from "../lib"; -import type { ClosedChat, EditorTab, RouteChatTarget, TerminalTab } from "./appStore"; +import type { + ClosedChat, + EditorTab, + OnboardingState, + RouteChatTarget, + SessionRuntime, + TerminalTab, +} from "./appStore"; interface ConnectionGenerationState { status: string; @@ -418,6 +425,58 @@ export function selectLastOpenChatSession( return null; } +export type OnboardingStep = 0 | 1 | 2 | 3; + +interface OnboardingDomainState { + onboarding: OnboardingState; + workspaces: Record; + sessions: Record; + tabsByWorkspace: Record; + closedChatsByWorkspace: Record; + layoutDocumentsByWorkspace?: Record; +} + +export function selectOnboardingActive(state: { onboarding: OnboardingState }): boolean { + return ( + state.onboarding.flow === "demo" && + !state.onboarding.dismissed && + state.onboarding.demoProjectId !== null + ); +} + +export function selectDemoWorkspaces(state: { + onboarding: OnboardingState; + workspaces: Record; +}): Workspace[] { + const projectId = state.onboarding.demoProjectId; + if (!projectId) return []; + return (state.workspaces[projectId] ?? []).filter((ws) => !isDefaultWorkspace(ws)); +} + +export function selectAgentStarted( + state: { + sessions: Record; + tabsByWorkspace: Record; + closedChatsByWorkspace: Record; + layoutDocumentsByWorkspace?: Record; + }, + workspaceId: string, +): boolean { + return selectWorkspaceSessionIds(state, workspaceId).some((id) => { + const rt = state.sessions[id]; + return rt ? rt.turns.some((turn) => turn.kind === "user") : false; + }); +} + +export function selectOnboardingStep(state: OnboardingDomainState): OnboardingStep { + const demoWorkspaces = selectDemoWorkspaces(state); + if (demoWorkspaces.length < 2) return 0; + const [first, second] = demoWorkspaces; + if (!first || !selectAgentStarted(state, first.id)) return 1; + if (!second || !selectAgentStarted(state, second.id)) return 2; + return 3; +} + export function selectReviewDraftCount( state: { reviewsByWorkspace: Record }, workspaceId: string | null, From 77fedeaf44bff60194ba8c64c1e7070c7e4f271d Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 20:59:04 +0300 Subject: [PATCH 06/23] =?UTF-8?q?feat(web):=20onboarding=20coach=20?= =?UTF-8?q?=E2=80=94=20contextual=20coach-marks=20for=20the=20demo=20tour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds apps/web/src/onboarding: OnboardingCoach (one shell-mounted overlay that anchors a Radix popover to real UI elements by stable attribute, tracked per animation frame), the pure selectCoach step mapping, and the startDemo/resetDemo orchestration. Three steps: create two workspaces → start the first agent → run a second agent in parallel; each advances on the real action. Adds anchor attributes to the rail (attributes only) and mounts the coach in the shell. New submodule-web-onboarding SPEC. --- apps/web/SPEC.md | 4 +- apps/web/src/onboarding/OnboardingCoach.tsx | 104 ++++++++++++++++++++ apps/web/src/onboarding/SPEC.md | 76 ++++++++++++++ apps/web/src/onboarding/coach.ts | 85 ++++++++++++++++ apps/web/src/onboarding/demo.ts | 34 +++++++ apps/web/src/onboarding/index.ts | 4 + apps/web/src/panels/ProjectTree.tsx | 3 + apps/web/src/shell/Shell.tsx | 2 + 8 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/onboarding/OnboardingCoach.tsx create mode 100644 apps/web/src/onboarding/SPEC.md create mode 100644 apps/web/src/onboarding/coach.ts create mode 100644 apps/web/src/onboarding/demo.ts create mode 100644 apps/web/src/onboarding/index.ts diff --git a/apps/web/SPEC.md b/apps/web/SPEC.md index c0624c563..c1217bb07 100644 --- a/apps/web/SPEC.md +++ b/apps/web/SPEC.md @@ -34,6 +34,7 @@ convention; their boundary is held by convention + spec. Sibling edges live here | `transport` | the WS client + its singleton/store wiring | yes | [transport/SPEC.md](src/transport/SPEC.md) | | `store` | Zustand: domain projections, accepted workspace-layout snapshots, local attention, chat runtimes | yes | [store/SPEC.md](src/store/SPEC.md) | | `panels` | layout-agnostic, store-driven feature views | no | [panels/SPEC.md](src/panels/SPEC.md) | +| `onboarding` | the first-run demo tour: entry + contextual coach-mark steps + reset | yes | [onboarding/SPEC.md](src/onboarding/SPEC.md) | | `chat` | pi conversation UI primitives: content-block renderers + the tool-renderer registry | no | [chat/SPEC.md](src/chat/SPEC.md) | | `auth` | in-app provider login: the presentational OAuth dialog + its client-side state reducer | yes | [auth/SPEC.md](src/auth/SPEC.md) | | `shell` | the responsive frame + synchronized workbench composition (with bounded child `layout/`) | no | [shell/SPEC.md](src/shell/SPEC.md) | @@ -58,7 +59,8 @@ screen, not a blank root). ### Dependency graph - `navigation` → `store`, `transport`, `contracts` (type-only); neither dependency imports it, and `main.tsx` initializes the integration -- `shell` → child `shell/layout`, `panels`, `chat` (app-integration render/hydration only), `store`, `transport`, `contracts` (type-only), `components/ui`, `components` (`ErrorBoundary` around each mounted region), `constants`, `lib` (platform shortcut semantics), `themes` (the single owner of the atomic `applyTheme` DOM effect, driven by `store.theme`) +- `shell` → child `shell/layout`, `panels`, `onboarding` (mounts `OnboardingCoach` beside `Toaster`), `chat` (app-integration render/hydration only), `store`, `transport`, `contracts` (type-only), `components/ui`, `components` (`ErrorBoundary` around each mounted region), `constants`, `lib` (platform shortcut semantics), `themes` (the single owner of the atomic `applyTheme` DOM effect, driven by `store.theme`) +- `onboarding` → `store`, `transport`, `components/ui`, `lib`, `contracts`; mounted by `shell`, initialized in `main.tsx`; `panels/WelcomePanel` calls its `startDemo`/`resetDemo` (one-way panels→onboarding edge, no cycle) - `shell/layout` → `contracts` (types only), `lib` (attention/id primitives), and React / `react-resizable-panels` / `@dnd-kit/core`; the parent injects store state, commit callbacks, and feature renderers, so the child has no feature-module runtime edge - `panels` → `store`, `transport`, `components/ui`, `components` (`ErrorBoundary` for feature bodies), `lib`, `contracts`, `constants` (`WelcomePanel`'s wordmark), `chat` (`NewWorkspaceDialog` eagerly reuses `chat/ModelSelector`+`ThinkingSelector`+`useModelCatalog` — these are shiki-free, so the eager import stays split-safe; `TemplatesSettings` reuses `chat/TemplateEditorDialog` for its New/Edit flows — see `panels/SPEC.md`'s `TemplatesSettings` paragraph), `auth` (`ProvidersSettings` mounts `auth/LoginDialog`), `themes` (`AppearanceSettings` consumes the live catalog; code surfaces consume generic theme variables/syntax mapping) - `chat` → `contracts` (pi message types, **type-only**), `components/ui`, `lib`; `store` + `transport` diff --git a/apps/web/src/onboarding/OnboardingCoach.tsx b/apps/web/src/onboarding/OnboardingCoach.tsx new file mode 100644 index 000000000..7d0bf107a --- /dev/null +++ b/apps/web/src/onboarding/OnboardingCoach.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { Button } from "../components/ui/button"; +import { Popover, PopoverAnchor, PopoverContent } from "../components/ui/popover"; +import { useAppStore } from "../store"; +import { type CoachStep, selectCoach } from "./coach"; +import { resetDemo } from "./demo"; + +export function OnboardingCoach() { + const coach = useAppStore(useShallow(selectCoach)); + if (!coach) return null; + if (coach.done) return ; + return ; +} + +function useTargetRect(selector: string): DOMRect | null { + const [rect, setRect] = useState(null); + useEffect(() => { + let frame = 0; + const measure = () => { + const element = document.querySelector(selector); + setRect(element ? element.getBoundingClientRect() : null); + frame = requestAnimationFrame(measure); + }; + measure(); + return () => cancelAnimationFrame(frame); + }, [selector]); + return rect; +} + +function StepPopover({ coach }: { coach: CoachStep }) { + const rect = useTargetRect(coach.selector); + const dismiss = useAppStore((s) => s.dismissOnboarding); + const setChatDraft = useAppStore((s) => s.setChatDraft); + if (!rect) return null; + + return ( + + +
    + + +

    Step {coach.index} of 3

    +

    {coach.title}

    +

    {coach.body}

    +
    + + {coach.insertPrompt && coach.sessionId ? ( + + ) : null} +
    +
    + + ); +} + +function DonePopover() { + const dismiss = useAppStore((s) => s.dismissOnboarding); + return ( +
    +
    +

    You're all set

    +

    + You created two isolated workspaces and ran agents in parallel — that's the ThinkRail + loop. +

    +
    + + +
    +
    +
    + ); +} diff --git a/apps/web/src/onboarding/SPEC.md b/apps/web/src/onboarding/SPEC.md new file mode 100644 index 000000000..40398db17 --- /dev/null +++ b/apps/web/src/onboarding/SPEC.md @@ -0,0 +1,76 @@ +--- +id: submodule-web-onboarding +type: submodule-design +status: active +title: onboarding — the demo tour +parent: module-web +depends-on: [module-contracts, submodule-web-store, submodule-web-transport, submodule-server-demo] +tags: [v1, ui, onboarding] +--- + +## Responsibility + +The first-run **demo tour**: enter the bundled To Do App demo, then guide a newcomer through three +**contextual coach-mark steps** that ride on the *real* ThinkRail interface (not a separate wizard), and +let them reset the demo to replay. It owns the tour's orchestration + overlay; the demo project itself is +a normal Project (server `demo` module) and every step advances by the user performing a **real** action, +never a synthetic one. + +## Behaviour + +- **Entry (`startDemo`).** `demo.ensure` → adopt the returned Project (`applyProjectUpdated`), arm the + flow (`store.startOnboarding(projectId)`), select the project (lands on its Welcome), and load its + workspaces so the rail reveals it. Errors degrade to a toast. Invoked by the Welcome "Try the To Do + App" card (panels). +- **The three steps** (`selectCoach`, pure over store state; the current step is *derived*, never a stored + counter, so it auto-advances and self-heals across reloads): + 1. **Create separate workspaces** — teaches the worktree/workspace model *before* any prompt. Anchors + the demo Welcome's "Start building" card while zero non-Default workspaces exist, then re-anchors the + rail `+` for the demo project once one exists. Completes at **two** non-Default demo workspaces. + 2. **Start the first agent** — anchors the **first** workspace's chat composer (guiding a switch to it + first if it isn't active) and offers one-click **Insert** of *"Add search functionality to the To Do + app."* Completes when that workspace's session has a sent user turn. + 3. **Run agents in parallel** — anchors the **second** (already-created) workspace: guides the user to + switch to it, then anchors its composer and offers **Insert** of *"Add a filter for completed + tasks."* Completes when that workspace's session has a sent user turn. The first agent keeps running, + so parallelism is shown directly. This step never *creates* the second workspace — creation is step 1. + A fourth **done** state ("You're all set") offers **Reset demo** + **Done**. +- **Auto-advance** is a consequence of the derived step: the step selectors read domain state (demo + workspace count; whether each workspace's chat has a `kind: "user"` turn — `selectOnboardingStep` / + `selectAgentStarted` / `selectDemoWorkspaces` in `store`). No wire traffic, no per-step store writes. +- **Reset (`resetDemo`).** `demo.reset` (archives the demo's workspaces, drops the record, deletes the + copy) → re-`project.list` and re-install the snapshot (dropping the demo from Recents) → clear the + onboarding slice. The demo's archived workspaces self-clear via the server's `workspace.removed` + broadcasts. Replayable: back at the empty first-run Welcome, the "Try the To Do App" card returns. +- **Skip** dismisses the whole flow (`store.dismissOnboarding`) without touching the demo project. + +## Coach-mark mechanism + +- **One shell-mounted overlay** (`OnboardingCoach`); the shell composes it beside `Toaster`. Panels stay + layout-agnostic — the coach never imports panels, and resolves each step's anchor by a stable + attribute (`[data-testid="welcome-cta"]`, `[data-testid="chat-input"]`, `[data-onboarding="rail-add"]` + + `[data-project-id]`, `[data-onboarding-ws]`) via `document.querySelector`, measured each animation + frame so it tracks scroll/layout/late mounts. A missing target simply hides the popover (never + mispoints). +- Renders the existing Radix `components/ui/popover` against a zero-size `PopoverAnchor` placed at the + target's rect (fixed position); a `border-primary-muted` ring marks the target. **No full-screen + scrim** — the real UI the user must click stays interactive (the ring/anchor is `pointer-events-none`). +- Geometry (left/top/width/height) is the only inline `style`; all colour/spacing/typography use semantic + token utilities (precedent: `chat/turns` uses inline style for a dynamic transition duration). + +## Persistence + +Per-browser localStorage under a host-qualified key (mirrors `panels/projectExpansion`): the slice +(`flow` / `demoProjectId` / `dismissed`) is hydrated at boot (`initOnboardingPersistence`, wired in +`main.tsx`) and written on change, so the tour resumes where the user left it. Untrusted reads, +best-effort writes. + +## Boundary + +- **Public surface (barrel):** `OnboardingCoach`, `startDemo`, `resetDemo`, `selectCoach`, + `initOnboardingPersistence`, `readPersistedOnboarding`. +- **Allowed deps:** `store` (slice + selectors + `toast`), `transport` (`getTransport`/`errorText`), + `components/ui` (`popover`, `button`), `lib`, `contracts`, `lucide-react`. +- **Forbidden:** `panels`, `shell` internals, `server`/`shared`/`pi`. (`panels/WelcomePanel` may call the + `startDemo`/`resetDemo` orchestration — a one-way panels→onboarding edge; onboarding never imports + panels, so there is no cycle.) diff --git a/apps/web/src/onboarding/coach.ts b/apps/web/src/onboarding/coach.ts new file mode 100644 index 000000000..ec6ede0f0 --- /dev/null +++ b/apps/web/src/onboarding/coach.ts @@ -0,0 +1,85 @@ +import { + selectDemoWorkspaces, + selectLastOpenChatSession, + selectOnboardingActive, + selectOnboardingStep, + type useAppStore, +} from "../store"; + +type AppStoreState = ReturnType; + +export const SEARCH_PROMPT = "Add search functionality to the To Do app."; +export const FILTER_PROMPT = "Add a filter for completed tasks."; + +export interface CoachStep { + done?: false; + index: 1 | 2 | 3; + title: string; + body: string; + selector: string; + insertPrompt?: string; + sessionId?: string; +} + +export interface CoachDone { + done: true; +} + +export type CoachView = CoachStep | CoachDone | null; + +export function selectCoach(state: AppStoreState): CoachView { + if (!selectOnboardingActive(state)) return null; + const demoProjectId = state.onboarding.demoProjectId; + if (!demoProjectId) return null; + + const step = selectOnboardingStep(state); + if (step === 3) return { done: true }; + + const demoWorkspaces = selectDemoWorkspaces(state); + + if (step === 0) { + if (demoWorkspaces.length === 0) { + return { + index: 1, + title: "Create your first workspace", + body: "ThinkRail runs each task in its own isolated worktree and branch. Create two workspaces so you can work on two tasks side by side — start with this one.", + selector: '[data-testid="welcome-cta"]', + }; + } + return { + index: 1, + title: "Create a second workspace", + body: "One down. Create a second workspace for the other task — each stays isolated on its own branch.", + selector: `[data-onboarding="rail-add"][data-project-id="${demoProjectId}"]`, + }; + } + + const target = demoWorkspaces[step === 1 ? 0 : 1]; + if (!target) return null; + const index = step === 1 ? 2 : 3; + + if (state.activeWorkspaceId !== target.id) { + return { + index, + title: step === 1 ? "Open your first workspace" : "Switch to your second workspace", + body: + step === 1 + ? "Open the first workspace to start its agent." + : "Switch to your second workspace — your first agent keeps running while this one starts.", + selector: `[data-onboarding-ws="${target.id}"]`, + }; + } + + const sessionId = selectLastOpenChatSession(state, target.id); + return { + index, + title: step === 1 ? "Start the first agent" : "Run a second agent in parallel", + body: + step === 1 + ? "Ask the agent to build the first feature, then send it." + : "Start the second task here. Both agents run at the same time — that's parallel work.", + selector: '[data-testid="chat-input"]', + insertPrompt: step === 1 ? SEARCH_PROMPT : FILTER_PROMPT, + ...(sessionId ? { sessionId } : {}), + }; +} diff --git a/apps/web/src/onboarding/demo.ts b/apps/web/src/onboarding/demo.ts new file mode 100644 index 000000000..b9df9838f --- /dev/null +++ b/apps/web/src/onboarding/demo.ts @@ -0,0 +1,34 @@ +import { toast, useAppStore } from "../store"; +import { errorText, getTransport } from "../transport"; + +export async function startDemo(): Promise { + const store = useAppStore.getState(); + try { + const project = await getTransport().request("demo.ensure", {}); + store.applyProjectUpdated(project); + store.startOnboarding(project.id); + store.selectProject(project.id, { reveal: true }); + const rows = await getTransport().request("workspace.list", { projectId: project.id }); + useAppStore.getState().setWorkspaces(project.id, rows); + } catch (err) { + toast.error(errorText(err, "Couldn't start the To Do App demo.")); + } +} + +export async function resetDemo(): Promise { + const demoProjectId = useAppStore.getState().onboarding.demoProjectId; + try { + await getTransport().request("demo.reset", {}); + } catch (err) { + toast.error(errorText(err, "Couldn't reset the demo.")); + return; + } + try { + const open = await getTransport().request("project.list", {}); + const recent = useAppStore + .getState() + .recentProjects.filter((project) => project.id !== demoProjectId); + useAppStore.getState().installProjectSnapshot(open, recent); + } catch {} + useAppStore.getState().resetOnboarding(); +} diff --git a/apps/web/src/onboarding/index.ts b/apps/web/src/onboarding/index.ts new file mode 100644 index 000000000..2a5c1f9f0 --- /dev/null +++ b/apps/web/src/onboarding/index.ts @@ -0,0 +1,4 @@ +export { selectCoach } from "./coach"; +export { resetDemo, startDemo } from "./demo"; +export { OnboardingCoach } from "./OnboardingCoach"; +export { initOnboardingPersistence, readPersistedOnboarding } from "./persistence"; diff --git a/apps/web/src/panels/ProjectTree.tsx b/apps/web/src/panels/ProjectTree.tsx index 9d58cbf77..8336426c1 100644 --- a/apps/web/src/panels/ProjectTree.tsx +++ b/apps/web/src/panels/ProjectTree.tsx @@ -386,6 +386,8 @@ function ProjectRow({
    ); } From 857681b997b9687724fd646d10b9e388217c5c46 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 21:00:56 +0300 Subject: [PATCH 07/23] feat(web): Welcome demo entry card + Reset demo button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 'Try the To Do App' card to the no-projects Welcome state (calls onboarding.startDemo) and a quiet 'Reset demo' button on the demo project's Welcome (calls onboarding.resetDemo). Panels SPEC documents the cards and the one-way panels→onboarding edge. --- apps/web/src/panels/SPEC.md | 12 +++++++++--- apps/web/src/panels/WelcomePanel.tsx | 24 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/web/src/panels/SPEC.md b/apps/web/src/panels/SPEC.md index 7cfa7eac6..ed999193d 100644 --- a/apps/web/src/panels/SPEC.md +++ b/apps/web/src/panels/SPEC.md @@ -169,7 +169,9 @@ label + explainer bottom-left; the primary is a filled-primary card carrying the hook, others quiet `welcome-action`s). Welcome is **the mode fork**: with a project shown it always pairs **"Start building"** (isolated worktree) with **"Work in project folder"** (the Default workspace) so the two working modes are a visible choice, not a hidden default. The cards by state: **no projects** → -**"Open project"** (one card); **project + `hasSpecs`** → **"Start building"** (primary) + "Work in +**"Open project"** + **"Try the To Do App"** (`data-testid="welcome-demo"`, `Sparkles`) — the demo entry +that calls `onboarding`'s `startDemo` (materialize the bundled demo via `demo.ensure`, arm the onboarding +tour, land on the demo's Welcome); **project + `hasSpecs`** → **"Start building"** (primary) + "Work in project folder"; **project + no specs** → a spec-first **"Set up project"** (primary) + "Start building" + "Work in project folder". **"Open project" appears only in the no-projects state** — where it's the only possible action; once a project is shown, opening another is the projects-rail **"+"** (the same @@ -178,7 +180,10 @@ dropdown), so Welcome stays the *work-in-this-project* surface. That card hangs / Recents). Recents is the store's `recentProjects`: one last-opened path list containing open + closed records with no status badge; selecting either runs the shared open flow and lands at Project Home, with a closed record retaining its id and workspace state. `Card` is a `forwardRef` usable as a Radix `asChild` -trigger. **"Work in project folder"** +trigger. When the shown project **is** the active demo (`store.onboarding.demoProjectId`), Welcome also +renders a quiet **"Reset demo"** text button (`data-testid="welcome-reset-demo"`) that calls +`onboarding`'s `resetDemo` — the replay door (see [[submodule-web-onboarding]]). The demo entry/reset +orchestration lives in `onboarding` (a one-way panels→onboarding edge); Welcome never owns the tour. **"Work in project folder"** (`House` icon, matching the rail's Default row) **direct-enters** the Default workspace — no dialog: the shared `enterDefaultWorkspace` helper lists the project's workspaces, stores them, and activates the `kind === "default"` row; an older host with no Default row degrades to an error toast. **"Start building"** is the @@ -450,7 +455,8 @@ a project picker, the prompt hero, and the reused panes, singleton side tools, terminal bodies, Settings, and `Toaster`), imported **per-file** so Monaco/shiki/xterm stay lazy. Tab strips, group headers, side stacks, and center topology are not panel surfaces; the shell layout module wraps these renderers. -- **Allowed deps:** `store`, `transport`, `components/ui` (incl. `popover`/`command`/`textarea` for the +- **Allowed deps:** `onboarding` (`WelcomePanel` calls its `startDemo`/`resetDemo` orchestration — one-way, + no cycle); `store`, `transport`, `components/ui` (incl. `popover`/`command`/`textarea` for the dialog), `chat` (`ModelSelector`/`ThinkingSelector` + the `useModelCatalog` hook that feeds them, reused by `NewWorkspaceDialog`; `Markdown`, reused by `MarkdownPreview`; `TemplateEditorDialog`, reused by `TemplatesSettings`), `lib`, `themes` (catalog + generic application contract), diff --git a/apps/web/src/panels/WelcomePanel.tsx b/apps/web/src/panels/WelcomePanel.tsx index 791ae1dac..7cdc3282f 100644 --- a/apps/web/src/panels/WelcomePanel.tsx +++ b/apps/web/src/panels/WelcomePanel.tsx @@ -3,6 +3,7 @@ import { FolderOpen, House, type LucideIcon, Rocket, Sparkles } from "lucide-rea import { type ComponentPropsWithoutRef, forwardRef, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { PRODUCT_NAME } from "../constants/branding"; +import { resetDemo, startDemo } from "../onboarding/demo"; import { useAppStore } from "../store"; import { getTransport } from "../transport"; import { AddProjectMenu } from "./AddProjectMenu"; @@ -21,6 +22,7 @@ export function WelcomePanel() { const projects = useAppStore((s) => s.projects); const recentProjects = useAppStore((s) => s.recentProjects); const selectedProjectId = useAppStore((s) => s.selectedProjectId); + const demoProjectId = useAppStore((s) => s.onboarding.demoProjectId); const [dialog, setDialog] = useState<{ projectId: string; prompt: string; @@ -109,7 +111,16 @@ export function WelcomePanel() {
    {noProjects ? ( - openProjectCard() + <> + {openProjectCard()} + void startDemo()} + /> + ) : hasSpecs === null ? null : hasSpecs ? ( <> + {project && demoProjectId === project.id ? ( + + ) : null} + {dialog ? ( Date: Mon, 24 Aug 2026 21:06:44 +0300 Subject: [PATCH 08/23] fix(server): demo.reset drops all workspace records incl Default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.reset called forgetWorkspace on the demo's Default workspace, which throws by design (Default is non-removable). Add forgetProjectWorkspaces — a project-deletion primitive that drops every record for a project (including user-owned kinds) and emits removed for each — and have demo.reset tear down + reclaim worktrees, then bulk-forget, deleteProject, and remove files. Worktree reclaim still refuses user-owned kinds. --- packages/server/src/host/handlers.ts | 9 +++------ packages/server/src/workspaces/SPEC.md | 11 +++++++++-- packages/server/src/workspaces/workspaces.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/server/src/host/handlers.ts b/packages/server/src/host/handlers.ts index 7e4948f37..803007b17 100644 --- a/packages/server/src/host/handlers.ts +++ b/packages/server/src/host/handlers.ts @@ -144,6 +144,7 @@ import { ensureWatch, stopWatch } from "../watch"; import { createWorkspace, ensureWorkspaceScratchDir, + forgetProjectWorkspaces, forgetWorkspace, getWorkspace, listExistingWorktrees, @@ -274,12 +275,8 @@ const handlers: Record = { const target = canonicalPath(demoProjectPath()); const project = getProjects().find((p) => canonicalPath(p.path) === target); if (project) { - await Promise.all( - listWorkspaceRecords(project.id).map((record) => { - const ws = forgetWorkspace(record.id); - return ws ? teardownWorkspace(ws) : Promise.resolve(); - }), - ); + await Promise.all(listWorkspaceRecords(project.id).map((ws) => teardownWorkspace(ws))); + forgetProjectWorkspaces(project.id); deleteProject(project.id); } removeDemoFiles(); diff --git a/packages/server/src/workspaces/SPEC.md b/packages/server/src/workspaces/SPEC.md index a5a3313c9..9325c1697 100644 --- a/packages/server/src/workspaces/SPEC.md +++ b/packages/server/src/workspaces/SPEC.md @@ -105,7 +105,13 @@ place as `kind: "external"` — outside the data dir, never created or mutated h defense-in-depth, any record whose `worktreePath` resolves to the project folder** — the rm-fallback must never see the user's repo or an attached checkout, however a corrupt/hand-edited record got there), and `removeWorkspace(id)` (the synchronous composition of the two, kept for callers/tests that want the whole - archive in one call). + archive in one call), and **`forgetProjectWorkspaces(projectId)`** — the **project-deletion** drop: + removes **every** record for a project (including the user-owned `default`/`external` kinds that + `forgetWorkspace` protects), emits `removed` for each, and returns them. Unlike `forgetWorkspace`, it is + allowed to drop a Default record because the *project itself is going away* (the demo-reset door in + `host`, paired with `deleteProject` + `removeDemoFiles`); it never touches git — worktree reclaim for the + managed rows is the caller's separate `reclaimWorktree`/`archiveTeardown` step, which still refuses the + user-owned kinds. - **Default workspace (`kind: "default"`):** exactly one per project. `listWorkspaces` **ensures** it — find-or-create by `projectId`+`kind` (id a plain `randomUUID`; the `kind` field is the marker, never an id convention), **collapsing duplicates** defensively if out-of-band state churn ever @@ -160,7 +166,8 @@ place as `kind: "external"` — outside the data dir, never created or mutated h module the **single source of workspace lifecycle pushes** (the auto-rename tee no longer pushes — rename self-publishes), so registry membership stays shared domain state across every client (architecture #9). - **Public surface (barrel):** `createWorkspace`, `listExistingWorktrees`, `openExistingWorktree`, - `listWorkspaces`, `listWorkspaceRecords`, `forgetWorkspace`, `reclaimWorktree`, `removeWorkspace`, + `listWorkspaces`, `listWorkspaceRecords`, `forgetWorkspace`, `forgetProjectWorkspaces`, + `reclaimWorktree`, `removeWorkspace`, `workspaceDiffStats`, `getWorkspace`, `renameWorkspace`, `refreshUserOwnedWorkspace`, `ensureWorkspaceScratchDir`, `setWorkspacePublisher`, `WorkspaceLifecycleEvent`. - **Allowed deps:** `projects` (repo lookup), `git` (the runner), `persistence`; `contracts`; diff --git a/packages/server/src/workspaces/workspaces.ts b/packages/server/src/workspaces/workspaces.ts index 48fdb784a..d5bcfa55b 100644 --- a/packages/server/src/workspaces/workspaces.ts +++ b/packages/server/src/workspaces/workspaces.ts @@ -450,6 +450,15 @@ export function forgetWorkspace(id: string): Workspace | null { return ws; } +export function forgetProjectWorkspaces(projectId: string): Workspace[] { + const all = loadWorkspaces(); + const removed = all.filter((w) => w.projectId === projectId); + if (removed.length === 0) return []; + saveWorkspaces(all.filter((w) => w.projectId !== projectId)); + for (const ws of removed) emit({ kind: "removed", projectId: ws.projectId, id: ws.id }); + return removed; +} + export function reclaimWorktree(ws: Workspace): void { if (ws.kind === "default" || ws.kind === "external") return; const project = loadProjects().find((p) => p.id === ws.projectId); From 3650543ca18432442b6b5be5c5d0c9f63e0a078b Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 21:07:17 +0300 Subject: [PATCH 09/23] =?UTF-8?q?test(e2e):=20onboarding=20demo=20?= =?UTF-8?q?=E2=80=94=20entry,=20two-workspace=20coaching,=20reset,=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-agent spec: the demo card enters the demo, the coach guides creating two workspaces (step 1 → step 2), Reset demo returns to the empty first-run state, and Skip hides the coach while keeping the project. --- e2e/onboarding-demo.spec.ts | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 e2e/onboarding-demo.spec.ts diff --git a/e2e/onboarding-demo.spec.ts b/e2e/onboarding-demo.spec.ts new file mode 100644 index 000000000..03e9ec8af --- /dev/null +++ b/e2e/onboarding-demo.spec.ts @@ -0,0 +1,60 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; +import { createWorkspaceViaDialog, openAppFresh, worktreeRows } from "./fixtures/app"; +import { E2E_DATA_DIR } from "./fixtures/paths"; + +test.beforeEach(() => { + rmSync(join(E2E_DATA_DIR, "demo"), { recursive: true, force: true }); +}); + +test("enter the demo, the coach guides creating two workspaces, then reset replays it", async ({ + page, +}) => { + await openAppFresh(page); + + const demoCard = page.getByTestId("welcome-demo"); + await expect(demoCard).toBeVisible(); + await expect(page.getByTestId("welcome-cta")).toContainText("Open project"); + await demoCard.click(); + + await expect(page.getByTestId("project-item")).toHaveCount(1); + await expect(page.getByTestId("welcome-cta")).toContainText("Start building"); + + const coach = page.getByTestId("onboarding-coach"); + await expect(coach).toContainText("Step 1 of 3"); + await page.getByTestId("welcome-cta").click(); + await expect(page.getByTestId("new-workspace-dialog")).toBeVisible(); + await page.getByTestId("create-workspace").click(); + await expect(worktreeRows(page)).toHaveCount(1); + + await expect(coach).toContainText("Step 1 of 3"); + await createWorkspaceViaDialog(page); + await expect(worktreeRows(page)).toHaveCount(2); + + await expect(coach).toContainText("Step 2 of 3"); + + await page.getByTestId("project-item").first().getByTestId("project-name").click(); + await expect(page.getByTestId("welcome")).toBeVisible(); + await page.getByTestId("welcome-reset-demo").click(); + + await expect(page.getByTestId("project-item")).toHaveCount(0); + await expect(page.getByTestId("welcome-demo")).toBeVisible(); + await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); +}); + +test("skipping the tour hides the coach but keeps the demo project", async ({ page }) => { + await openAppFresh(page); + await page.getByTestId("welcome-demo").click(); + + const coach = page.getByTestId("onboarding-coach"); + await expect(coach).toContainText("Step 1 of 3"); + await page.getByTestId("onboarding-skip").click(); + + await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); + await expect(page.getByTestId("project-item")).toHaveCount(1); + + await page.getByTestId("project-item").first().getByTestId("project-name").click(); + await page.getByTestId("welcome-reset-demo").click(); + await expect(page.getByTestId("project-item")).toHaveCount(0); +}); From 1f0c01c785a7329438908eb1bbcab81865660d00 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 21:17:05 +0300 Subject: [PATCH 10/23] feat(server): display the demo project as "To Do App" Uses the Project model's existing display-name field (name, distinct from the path-derived slug): ensureDemoProject sets name via a new setProjectName registry setter, leaving the folder + slug as to-do-app. No new naming concept, no directory rename. e2e asserts the hero title. --- e2e/onboarding-demo.spec.ts | 1 + packages/server/src/demo/SPEC.md | 6 ++++-- packages/server/src/demo/demo.test.ts | 2 ++ packages/server/src/demo/demo.ts | 5 +++-- packages/server/src/projects/SPEC.md | 6 +++++- packages/server/src/projects/projects.ts | 11 +++++++++++ 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/e2e/onboarding-demo.spec.ts b/e2e/onboarding-demo.spec.ts index 03e9ec8af..d363448fa 100644 --- a/e2e/onboarding-demo.spec.ts +++ b/e2e/onboarding-demo.spec.ts @@ -19,6 +19,7 @@ test("enter the demo, the coach guides creating two workspaces, then reset repla await demoCard.click(); await expect(page.getByTestId("project-item")).toHaveCount(1); + await expect(page.getByTestId("welcome-title")).toHaveText("To Do App"); await expect(page.getByTestId("welcome-cta")).toContainText("Start building"); const coach = page.getByTestId("onboarding-coach"); diff --git a/packages/server/src/demo/SPEC.md b/packages/server/src/demo/SPEC.md index e3063ff23..acc5dfd5f 100644 --- a/packages/server/src/demo/SPEC.md +++ b/packages/server/src/demo/SPEC.md @@ -37,8 +37,10 @@ on it (see the web onboarding SPEC). managed worktree dirs keyed by project slug ([[submodule-server-workspaces]]). - `ensureDemoProject()` — idempotent: when the target is absent, copy the bundled template into it (never mutating the bundled source), then hand off to `initProject` (git init + initial commit, or a - short-circuit `openProject` when the repo already exists). Returns the `Project`. A second call - re-opens the existing record rather than re-initialising. + short-circuit `openProject` when the repo already exists) and set the display **`name`** to + "To Do App" via `projects`' `setProjectName` (the folder + `slug` stay `to-do-app`; the existing + display-name field, not a new naming concept). Returns the `Project`. A second call re-opens the + existing record rather than re-initialising. - `removeDemoFiles()` — `rm -rf` the user-local copy. The *domain* half of a reset (archiving the demo's workspaces + dropping the project record via `deleteProject`) is orchestrated by `host`, which can reach the per-workspace teardown seams this module must not (terminals, spec index, reviews, diff --git a/packages/server/src/demo/demo.test.ts b/packages/server/src/demo/demo.test.ts index 053ac6cca..b57c3e64c 100644 --- a/packages/server/src/demo/demo.test.ts +++ b/packages/server/src/demo/demo.test.ts @@ -33,6 +33,8 @@ test("ensureDemoProject copies the template, inits a real repo, and opens it", ( const project = ensureDemoProject(); expect(project.path).toBe(realpathSync(demoProjectPath())); + expect(project.name).toBe("To Do App"); + expect(project.slug).toBe("to-do-app"); expect(existsSync(join(demoProjectPath(), "index.html"))).toBe(true); expect(existsSync(join(demoProjectPath(), "src", "app.js"))).toBe(true); expect(gitOut(demoProjectPath(), "rev-parse", "HEAD")).not.toBe(""); diff --git a/packages/server/src/demo/demo.ts b/packages/server/src/demo/demo.ts index df81829eb..147411a09 100644 --- a/packages/server/src/demo/demo.ts +++ b/packages/server/src/demo/demo.ts @@ -2,9 +2,10 @@ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import type { Project } from "@thinkrail/contracts"; import { dataDir } from "../persistence"; -import { initProject } from "../projects"; +import { initProject, setProjectName } from "../projects"; export const DEMO_APP_DIR = "to-do-app"; +export const DEMO_DISPLAY_NAME = "To Do App"; function templateRoot(): string { return process.env.THINKRAIL_DEMO_DIR ?? resolve(import.meta.dir, "../../assets/demo"); @@ -22,7 +23,7 @@ export function ensureDemoProject(): Project { mkdirSync(join(dataDir(), "demo"), { recursive: true }); cpSync(source, target, { recursive: true }); } - return initProject(target); + return setProjectName(initProject(target).id, DEMO_DISPLAY_NAME); } export function removeDemoFiles(): void { diff --git a/packages/server/src/projects/SPEC.md b/packages/server/src/projects/SPEC.md index 6d9124f34..4d68564ba 100644 --- a/packages/server/src/projects/SPEC.md +++ b/packages/server/src/projects/SPEC.md @@ -41,7 +41,11 @@ bootstrap it into one so it can be opened. — `host` answers the lazy `project.hasSpecs` query via `spec.projectHasSpecs`, keeping this module free of any spec dependency.) - **Public surface (barrel):** `openProject`, `listProjects`, `listRecentProjects`, `closeProject`, - `deleteProject`, `getProjects`, `setProjectPublisher`, `inspectProjectPath`, `initProject`. + `deleteProject`, `setProjectName`, `getProjects`, `setProjectPublisher`, `inspectProjectPath`, + `initProject`. **`setProjectName(id, name)`** overwrites the **display `name`** only (never the + path-derived `slug`), persists, and emits `project.updated` — the demo project uses it for a friendly + "To Do App" title while its folder/slug stay `to-do-app`. `name` is display-only; `openProject` seeds it + from the folder basename at first open and never overwrites it on reopen, so a set name survives. - **Allowed deps:** `persistence`; the `git` sub-module (shared `git()` runner, bound to live `env` for config overrides); `contracts` (`Project`, `ProjectPathStatus`); Node/Bun. - **Forbidden:** `host`; sibling features other than `git` (`workspaces` depends on `projects`, never the diff --git a/packages/server/src/projects/projects.ts b/packages/server/src/projects/projects.ts index 207705b1d..9a827188a 100644 --- a/packages/server/src/projects/projects.ts +++ b/packages/server/src/projects/projects.ts @@ -124,6 +124,17 @@ export function closeProject(id: string): Project { return project; } +export function setProjectName(id: string, name: string): Project { + const projects = getProjects(); + const project = projects.find((p) => p.id === id); + if (!project) throw new Error(`Unknown project: ${id}`); + if (project.name === name) return project; + project.name = name; + saveProjects(projects); + emit(project); + return project; +} + export function setProjectTrust( id: string, trusted: boolean, From 4b4bde30b2665ac3d028a823a14e6b014fff14b1 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 21:35:21 +0300 Subject: [PATCH 11/23] =?UTF-8?q?chore(web):=20TEMP=20=E2=80=94=20show=20d?= =?UTF-8?q?emo=20card=20in=20project=20Welcome=20for=20design=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the existing 'Try the To Do App' card (same demoCard element, same demo.ensure behavior/copy/styling) alongside the project Welcome cards in both has-specs and no-specs states, not only the empty first-run state. Frontend-only, no wire/contract change. Temporary review aid for this branch — revert this commit to restore empty-state-only placement. --- apps/web/src/panels/WelcomePanel.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/src/panels/WelcomePanel.tsx b/apps/web/src/panels/WelcomePanel.tsx index 7cdc3282f..02436a820 100644 --- a/apps/web/src/panels/WelcomePanel.tsx +++ b/apps/web/src/panels/WelcomePanel.tsx @@ -77,6 +77,16 @@ export function WelcomePanel() { /> ); + const demoCard = () => ( + void startDemo()} + /> + ); + const openProjectCard = () => ( {openProjectCard()} - void startDemo()} - /> + {demoCard()} ) : hasSpecs === null ? null : hasSpecs ? ( <> @@ -132,6 +136,7 @@ export function WelcomePanel() { onClick={() => setDialog({ projectId: project.id, prompt: "" })} /> {projectFolderCard(project.id)} + {demoCard()} ) : ( <> @@ -157,6 +162,7 @@ export function WelcomePanel() { onClick={() => setDialog({ projectId: project.id, prompt: "" })} /> {projectFolderCard(project.id)} + {demoCard()} )}
    From e073f8b3365bae25140c9205db9bb90bca46b384 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 22:06:12 +0300 Subject: [PATCH 12/23] feat(web): manual-launch demo tour from a simulated empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent Help-style OnboardingLauncher at the bottom of the left panel (ProjectTree footer) that starts the demo from a simulated empty first-run state — view-only, never touching the real project registry. Flow: step 1 spotlights a simulated empty Welcome 'Open project' card, then a simulated (frontend-only) folder picker offering a single 'to-do-app' folder; selecting it runs the real demo.ensure and continues into the existing live coach (steps 2-4: create two workspaces, first agent, parallel agent). All steps now use one Spotlight treatment: a container-workspace-overlay (workspace bg @ veil/70% alpha, a sanctioned color token) dim around a clear, interactive target + a tooltip with an arrow; coach marks are non-dismissible, with a separate Exit control that restores the normal UI. Replaces the temporary always-visible Welcome demo card (empty-state card retained as a direct entry). Frontend-only; no wire/contract/backend changes. Specs + e2e updated. --- apps/web/src/components/ui/popover.tsx | 11 +- apps/web/src/onboarding/OnboardingCoach.tsx | 73 +++-------- apps/web/src/onboarding/OnboardingDemo.tsx | 123 ++++++++++++++++++ .../web/src/onboarding/OnboardingLauncher.tsx | 19 +++ apps/web/src/onboarding/SPEC.md | 77 +++++++---- apps/web/src/onboarding/Spotlight.tsx | 97 ++++++++++++++ apps/web/src/onboarding/anchor.ts | 20 +++ apps/web/src/onboarding/coach.ts | 10 +- apps/web/src/onboarding/index.ts | 2 + apps/web/src/onboarding/persistence.ts | 2 + apps/web/src/panels/ProjectTree.tsx | 7 +- apps/web/src/panels/SPEC.md | 8 +- apps/web/src/panels/WelcomePanel.tsx | 2 - apps/web/src/shell/Shell.tsx | 4 +- apps/web/src/store/appStore.ts | 11 +- apps/web/src/store/onboarding.test.ts | 8 +- apps/web/src/store/selectors.ts | 1 + apps/web/src/styles/colors.json | 11 +- apps/web/src/styles/generated/colors.css | 4 +- e2e/onboarding-demo.spec.ts | 43 +++--- 20 files changed, 412 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/onboarding/OnboardingDemo.tsx create mode 100644 apps/web/src/onboarding/OnboardingLauncher.tsx create mode 100644 apps/web/src/onboarding/Spotlight.tsx create mode 100644 apps/web/src/onboarding/anchor.ts diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index fe8274dfa..1d1a7dbc5 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -6,6 +6,15 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; +function PopoverArrow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function PopoverContent({ className, align = "center", @@ -30,4 +39,4 @@ function PopoverContent({ ); } -export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }; +export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger }; diff --git a/apps/web/src/onboarding/OnboardingCoach.tsx b/apps/web/src/onboarding/OnboardingCoach.tsx index 7d0bf107a..ed6827ac1 100644 --- a/apps/web/src/onboarding/OnboardingCoach.tsx +++ b/apps/web/src/onboarding/OnboardingCoach.tsx @@ -1,62 +1,27 @@ -import { useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { Button } from "../components/ui/button"; -import { Popover, PopoverAnchor, PopoverContent } from "../components/ui/popover"; import { useAppStore } from "../store"; import { type CoachStep, selectCoach } from "./coach"; import { resetDemo } from "./demo"; +import { CoachBody, Spotlight } from "./Spotlight"; export function OnboardingCoach() { const coach = useAppStore(useShallow(selectCoach)); if (!coach) return null; - if (coach.done) return ; - return ; + if (coach.done) return ; + return ; } -function useTargetRect(selector: string): DOMRect | null { - const [rect, setRect] = useState(null); - useEffect(() => { - let frame = 0; - const measure = () => { - const element = document.querySelector(selector); - setRect(element ? element.getBoundingClientRect() : null); - frame = requestAnimationFrame(measure); - }; - measure(); - return () => cancelAnimationFrame(frame); - }, [selector]); - return rect; -} - -function StepPopover({ coach }: { coach: CoachStep }) { - const rect = useTargetRect(coach.selector); - const dismiss = useAppStore((s) => s.dismissOnboarding); +function StepSpotlight({ coach }: { coach: CoachStep }) { const setChatDraft = useAppStore((s) => s.setChatDraft); - if (!rect) return null; - return ( - - -
    - - -

    Step {coach.index} of 3

    -

    {coach.title}

    -

    {coach.body}

    -
    - - {coach.insertPrompt && coach.sessionId ? ( + + Insert prompt - ) : null} -
    -
    - + ) : undefined + } + /> + ); } -function DonePopover() { - const dismiss = useAppStore((s) => s.dismissOnboarding); +function DoneCard() { + const resetOnboarding = useAppStore((s) => s.resetOnboarding); return ( -
    +
    Reset demo -
    diff --git a/apps/web/src/onboarding/OnboardingDemo.tsx b/apps/web/src/onboarding/OnboardingDemo.tsx new file mode 100644 index 000000000..43a55db41 --- /dev/null +++ b/apps/web/src/onboarding/OnboardingDemo.tsx @@ -0,0 +1,123 @@ +import { Folder, FolderOpen, X } from "lucide-react"; +import { PRODUCT_NAME } from "../constants/branding"; +import { useAppStore } from "../store"; +import { startDemo } from "./demo"; +import { OnboardingCoach } from "./OnboardingCoach"; +import { CoachBody, Spotlight } from "./Spotlight"; + +export function OnboardingDemo() { + const stage = useAppStore((s) => s.onboarding.stage); + const resetOnboarding = useAppStore((s) => s.resetOnboarding); + if (!stage) return null; + return ( + <> + {stage === "welcome" ? : null} + {stage === "picker" ? : null} + {stage === "live" ? : null} + + + ); +} + +function DemoScaffold({ children }: { children: React.ReactNode }) { + return ( +
    +
    + {PRODUCT_NAME} +
    +
    + +
    + {children} +
    +
    +
    + ); +} + +function DemoEmptyWelcome() { + const setDemoStage = useAppStore((s) => s.setDemoStage); + return ( + <> + +
    +

    {PRODUCT_NAME}

    +
    + +
    +
    +
    + + + + + ); +} + +function DemoFolderPicker() { + return ( + <> + +
    +
    + + Home + / + Projects +
    +
      +
    • + +
    • +
    +
    +
    + + + + + ); +} diff --git a/apps/web/src/onboarding/OnboardingLauncher.tsx b/apps/web/src/onboarding/OnboardingLauncher.tsx new file mode 100644 index 000000000..dfa36408a --- /dev/null +++ b/apps/web/src/onboarding/OnboardingLauncher.tsx @@ -0,0 +1,19 @@ +import { GraduationCap } from "lucide-react"; +import { useAppStore } from "../store"; + +export function OnboardingLauncher() { + const startDemoTour = useAppStore((s) => s.startDemoTour); + return ( + + ); +} diff --git a/apps/web/src/onboarding/SPEC.md b/apps/web/src/onboarding/SPEC.md index 40398db17..7f8de50dc 100644 --- a/apps/web/src/onboarding/SPEC.md +++ b/apps/web/src/onboarding/SPEC.md @@ -10,11 +10,32 @@ tags: [v1, ui, onboarding] ## Responsibility -The first-run **demo tour**: enter the bundled To Do App demo, then guide a newcomer through three -**contextual coach-mark steps** that ride on the *real* ThinkRail interface (not a separate wizard), and -let them reset the demo to replay. It owns the tour's orchestration + overlay; the demo project itself is -a normal Project (server `demo` module) and every step advances by the user performing a **real** action, -never a synthetic one. +The **demo tour**: launchable any time from a persistent left-panel control, it opens on a **simulated +empty-first-run state** (view-only — the real project registry is never touched), teaches opening a +project through a **simulated folder picker**, then materializes the bundled To Do App demo and guides a +newcomer through the remaining **contextual coach-mark steps** on the *real* ThinkRail interface. Every +live step advances by the user performing a **real** action, never a synthetic one. + +All coach marks use one **spotlight** treatment: the viewport is dimmed with the +`container-workspace-overlay` scrim (the workspace surface at the `veil` 70% alpha step — a sanctioned +color token, see `styles/colors.json`), the single actionable target stays clear + interactive, and a +tooltip with an arrow points at it. A coach mark is **non-dismissible** — no close/Skip/Next, no +outside-click or Escape; it clears only when its action completes. Leaving the tour is a **separate** Exit +control (and the final done card), not a coach-mark dismissal. + +### Stages (`onboarding.stage`) + +- **`welcome`** (simulated) — a full-viewport opaque scaffold (`container-workspace-bg` shell chrome) + showing an empty Welcome with an "Open project" card, spotlighted ("Step 1 of 4 · Open a project"). + Clicking it advances to `picker`. No real project exists yet. +- **`picker`** (simulated) — a lightened Finder-like surface with a single selectable `to-do-app` folder, + spotlighted ("Choose your project folder"). This is a **controlled frontend simulation** — no real OS + picker, no filesystem/backend. Selecting it runs `startDemo` (real `demo.ensure`), which moves to `live`. +- **`live`** — the real ThinkRail UI with the real demo project; `OnboardingCoach` spotlights the real + controls for steps 2–4 (create two workspaces → first agent → second agent in parallel). + +The manual launcher enters at `welcome`; the empty-state Welcome "Try the To Do App" card enters directly +at `live` (skipping the simulation, since a genuinely-empty user needs no simulated empty state). ## Behaviour @@ -22,19 +43,20 @@ never a synthetic one. flow (`store.startOnboarding(projectId)`), select the project (lands on its Welcome), and load its workspaces so the rail reveals it. Errors degrade to a toast. Invoked by the Welcome "Try the To Do App" card (panels). -- **The three steps** (`selectCoach`, pure over store state; the current step is *derived*, never a stored - counter, so it auto-advances and self-heals across reloads): - 1. **Create separate workspaces** — teaches the worktree/workspace model *before* any prompt. Anchors +- **The live steps** (`selectCoach`, pure over store state; the current step is *derived*, never a stored + counter, so it auto-advances and self-heals across reloads; numbered *of 4* since the simulated + open-project stage is step 1): + 2. **Create separate workspaces** — teaches the worktree/workspace model *before* any prompt. Anchors the demo Welcome's "Start building" card while zero non-Default workspaces exist, then re-anchors the rail `+` for the demo project once one exists. Completes at **two** non-Default demo workspaces. - 2. **Start the first agent** — anchors the **first** workspace's chat composer (guiding a switch to it + 3. **Start the first agent** — anchors the **first** workspace's chat composer (guiding a switch to it first if it isn't active) and offers one-click **Insert** of *"Add search functionality to the To Do app."* Completes when that workspace's session has a sent user turn. - 3. **Run agents in parallel** — anchors the **second** (already-created) workspace: guides the user to + 4. **Run agents in parallel** — anchors the **second** (already-created) workspace: guides the user to switch to it, then anchors its composer and offers **Insert** of *"Add a filter for completed tasks."* Completes when that workspace's session has a sent user turn. The first agent keeps running, - so parallelism is shown directly. This step never *creates* the second workspace — creation is step 1. - A fourth **done** state ("You're all set") offers **Reset demo** + **Done**. + so parallelism is shown directly. This step never *creates* the second workspace — creation is step 2. + A final **done** state ("You're all set") offers **Reset demo** + **Done**. - **Auto-advance** is a consequence of the derived step: the step selectors read domain state (demo workspace count; whether each workspace's chat has a `kind: "user"` turn — `selectOnboardingStep` / `selectAgentStarted` / `selectDemoWorkspaces` in `store`). No wire traffic, no per-step store writes. @@ -42,33 +64,38 @@ never a synthetic one. copy) → re-`project.list` and re-install the snapshot (dropping the demo from Recents) → clear the onboarding slice. The demo's archived workspaces self-clear via the server's `workspace.removed` broadcasts. Replayable: back at the empty first-run Welcome, the "Try the To Do App" card returns. -- **Skip** dismisses the whole flow (`store.dismissOnboarding`) without touching the demo project. +- **Exit** (`onboarding-exit`, a fixed corner control shown for every stage) clears the tour view state + (`store.resetOnboarding`) and restores the normal UI; any real demo project already created remains as a + normal project. This is the only manual escape — coach marks themselves never dismiss. ## Coach-mark mechanism -- **One shell-mounted overlay** (`OnboardingCoach`); the shell composes it beside `Toaster`. Panels stay - layout-agnostic — the coach never imports panels, and resolves each step's anchor by a stable +- **One shell-mounted overlay** (`OnboardingDemo`); the shell composes it beside `Toaster`. It renders the + simulated `welcome`/`picker` scaffolds itself and delegates the `live` stage to `OnboardingCoach`. + Panels stay layout-agnostic — the overlay never imports panels, and resolves each target by a stable attribute (`[data-testid="welcome-cta"]`, `[data-testid="chat-input"]`, `[data-onboarding="rail-add"]` - + `[data-project-id]`, `[data-onboarding-ws]`) via `document.querySelector`, measured each animation - frame so it tracks scroll/layout/late mounts. A missing target simply hides the popover (never - mispoints). -- Renders the existing Radix `components/ui/popover` against a zero-size `PopoverAnchor` placed at the - target's rect (fixed position); a `border-primary-muted` ring marks the target. **No full-screen - scrim** — the real UI the user must click stays interactive (the ring/anchor is `pointer-events-none`). + + `[data-project-id]`, `[data-onboarding-ws]`, and the simulated `[data-onboarding="demo-open"]` / + `[data-onboarding="demo-folder"]`) via `document.querySelector`, measured each animation frame so it + tracks scroll/layout/late mounts. A missing target simply hides the tooltip (never mispoints). +- The shared **`Spotlight`** primitive draws four `container-workspace-overlay` dim rects around the target + rect (each `pointer-events-auto`, so the dimmed area both reads as inert and absorbs any outside click — + the non-dismissible guarantee), leaving the target hole clear + interactive, and renders the existing + Radix `components/ui/popover` (with a `PopoverArrow`) against a zero-size `PopoverAnchor` at the rect, + with Escape / outside-interaction handlers prevented. - Geometry (left/top/width/height) is the only inline `style`; all colour/spacing/typography use semantic token utilities (precedent: `chat/turns` uses inline style for a dynamic transition duration). ## Persistence Per-browser localStorage under a host-qualified key (mirrors `panels/projectExpansion`): the slice -(`flow` / `demoProjectId` / `dismissed`) is hydrated at boot (`initOnboardingPersistence`, wired in -`main.tsx`) and written on change, so the tour resumes where the user left it. Untrusted reads, +(`flow` / `stage` / `demoProjectId` / `dismissed`) is hydrated at boot (`initOnboardingPersistence`, wired +in `main.tsx`) and written on change, so the tour resumes where the user left it. Untrusted reads, best-effort writes. ## Boundary -- **Public surface (barrel):** `OnboardingCoach`, `startDemo`, `resetDemo`, `selectCoach`, - `initOnboardingPersistence`, `readPersistedOnboarding`. +- **Public surface (barrel):** `OnboardingDemo`, `OnboardingCoach`, `OnboardingLauncher`, `startDemo`, + `resetDemo`, `selectCoach`, `initOnboardingPersistence`, `readPersistedOnboarding`. - **Allowed deps:** `store` (slice + selectors + `toast`), `transport` (`getTransport`/`errorText`), `components/ui` (`popover`, `button`), `lib`, `contracts`, `lucide-react`. - **Forbidden:** `panels`, `shell` internals, `server`/`shared`/`pi`. (`panels/WelcomePanel` may call the diff --git a/apps/web/src/onboarding/Spotlight.tsx b/apps/web/src/onboarding/Spotlight.tsx new file mode 100644 index 000000000..f2eef9de5 --- /dev/null +++ b/apps/web/src/onboarding/Spotlight.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from "react"; +import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from "../components/ui/popover"; +import { useTargetRect } from "./anchor"; + +const DIM = "pointer-events-auto fixed z-40 bg-container-workspace-overlay"; + +export function Spotlight({ + selector, + side = "bottom", + align = "start", + children, +}: { + selector: string; + side?: "top" | "right" | "bottom" | "left"; + align?: "start" | "center" | "end"; + children: ReactNode; +}) { + const rect = useTargetRect(selector); + if (!rect) return null; + return ( + <> +
    +
    +
    +
    + + +
    + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + {children} + + + + + ); +} + +export function CoachBody({ + step, + title, + body, + action, +}: { + step: number; + title: string; + body: string; + action?: ReactNode; +}) { + return ( + <> +

    Step {step} of 4

    +

    {title}

    +

    {body}

    + {action ?
    {action}
    : null} + + ); +} diff --git a/apps/web/src/onboarding/anchor.ts b/apps/web/src/onboarding/anchor.ts new file mode 100644 index 000000000..eb3b115cb --- /dev/null +++ b/apps/web/src/onboarding/anchor.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +export function useTargetRect(selector: string | null): DOMRect | null { + const [rect, setRect] = useState(null); + useEffect(() => { + if (!selector) { + setRect(null); + return; + } + let frame = 0; + const measure = () => { + const element = document.querySelector(selector); + setRect(element ? element.getBoundingClientRect() : null); + frame = requestAnimationFrame(measure); + }; + measure(); + return () => cancelAnimationFrame(frame); + }, [selector]); + return rect; +} diff --git a/apps/web/src/onboarding/coach.ts b/apps/web/src/onboarding/coach.ts index ec6ede0f0..f1aa0bf78 100644 --- a/apps/web/src/onboarding/coach.ts +++ b/apps/web/src/onboarding/coach.ts @@ -13,7 +13,7 @@ export const FILTER_PROMPT = "Add a filter for completed tasks."; export interface CoachStep { done?: false; - index: 1 | 2 | 3; + index: 2 | 3 | 4; title: string; body: string; selector: string; @@ -21,6 +21,8 @@ export interface CoachStep { sessionId?: string; } +export const COACH_STEP_COUNT = 4; + export interface CoachDone { done: true; } @@ -40,14 +42,14 @@ export function selectCoach(state: AppStoreState): CoachView { if (step === 0) { if (demoWorkspaces.length === 0) { return { - index: 1, + index: 2, title: "Create your first workspace", body: "ThinkRail runs each task in its own isolated worktree and branch. Create two workspaces so you can work on two tasks side by side — start with this one.", selector: '[data-testid="welcome-cta"]', }; } return { - index: 1, + index: 2, title: "Create a second workspace", body: "One down. Create a second workspace for the other task — each stays isolated on its own branch.", selector: `[data-onboarding="rail-add"][data-project-id="${demoProjectId}"]`, @@ -56,7 +58,7 @@ export function selectCoach(state: AppStoreState): CoachView { const target = demoWorkspaces[step === 1 ? 0 : 1]; if (!target) return null; - const index = step === 1 ? 2 : 3; + const index = step === 1 ? 3 : 4; if (state.activeWorkspaceId !== target.id) { return { diff --git a/apps/web/src/onboarding/index.ts b/apps/web/src/onboarding/index.ts index 2a5c1f9f0..79002ea70 100644 --- a/apps/web/src/onboarding/index.ts +++ b/apps/web/src/onboarding/index.ts @@ -1,4 +1,6 @@ export { selectCoach } from "./coach"; export { resetDemo, startDemo } from "./demo"; export { OnboardingCoach } from "./OnboardingCoach"; +export { OnboardingDemo } from "./OnboardingDemo"; +export { OnboardingLauncher } from "./OnboardingLauncher"; export { initOnboardingPersistence, readPersistedOnboarding } from "./persistence"; diff --git a/apps/web/src/onboarding/persistence.ts b/apps/web/src/onboarding/persistence.ts index d52b9f7a9..32e871dc3 100644 --- a/apps/web/src/onboarding/persistence.ts +++ b/apps/web/src/onboarding/persistence.ts @@ -13,8 +13,10 @@ export function readPersistedOnboarding(): OnboardingState { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") return NO_ONBOARDING; const value = parsed as Record; + const stage = value.stage; return { flow: value.flow === "demo" ? "demo" : null, + stage: stage === "welcome" || stage === "picker" || stage === "live" ? stage : null, demoProjectId: typeof value.demoProjectId === "string" ? value.demoProjectId : null, dismissed: value.dismissed === true, }; diff --git a/apps/web/src/panels/ProjectTree.tsx b/apps/web/src/panels/ProjectTree.tsx index 8336426c1..2cbbb9437 100644 --- a/apps/web/src/panels/ProjectTree.tsx +++ b/apps/web/src/panels/ProjectTree.tsx @@ -33,6 +33,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { copyText } from "@/lib"; +import { OnboardingLauncher } from "../onboarding/OnboardingLauncher"; import { isDefaultWorkspace, isExternalWorkspace, @@ -209,7 +210,7 @@ export function ProjectTree() { }; return ( -
    ); } diff --git a/apps/web/src/panels/SPEC.md b/apps/web/src/panels/SPEC.md index ed999193d..7a81fe505 100644 --- a/apps/web/src/panels/SPEC.md +++ b/apps/web/src/panels/SPEC.md @@ -20,6 +20,9 @@ arrangement (so the mobile shell is an additive layer, not a rewrite). **always-visible chevron** + folder/name + a collapsed-only plain workspace count + a **bare muted Create workspace `+` always visible in a fixed right-edge column** (the Projects-header Add project `+` is unchanged). Long names truncate before the count/action; there is deliberately **no visible Close or overflow icon**. + The nav is a full-height column with a **bottom-pinned footer** hosting the onboarding + **`OnboardingLauncher`** (a Help-style icon button that starts the demo tour from its simulated empty + state — see [[submodule-web-onboarding]]). Hover highlights the full row and the highlight remains while its **project context menu** is open. Right-click opens that PR-#167-styled menu at the pointer without selecting/navigating; a scroll-cancelled ~700ms long press is its touch equivalent. With a project-name button focused, the standard Context Menu @@ -455,8 +458,9 @@ a project picker, the prompt hero, and the reused panes, singleton side tools, terminal bodies, Settings, and `Toaster`), imported **per-file** so Monaco/shiki/xterm stay lazy. Tab strips, group headers, side stacks, and center topology are not panel surfaces; the shell layout module wraps these renderers. -- **Allowed deps:** `onboarding` (`WelcomePanel` calls its `startDemo`/`resetDemo` orchestration — one-way, - no cycle); `store`, `transport`, `components/ui` (incl. `popover`/`command`/`textarea` for the +- **Allowed deps:** `onboarding` (`WelcomePanel` calls its `startDemo`/`resetDemo` orchestration and + `ProjectTree` mounts its `OnboardingLauncher` at the left-panel footer — one-way panels→onboarding, no + cycle); `store`, `transport`, `components/ui` (incl. `popover`/`command`/`textarea` for the dialog), `chat` (`ModelSelector`/`ThinkingSelector` + the `useModelCatalog` hook that feeds them, reused by `NewWorkspaceDialog`; `Markdown`, reused by `MarkdownPreview`; `TemplateEditorDialog`, reused by `TemplatesSettings`), `lib`, `themes` (catalog + generic application contract), diff --git a/apps/web/src/panels/WelcomePanel.tsx b/apps/web/src/panels/WelcomePanel.tsx index 02436a820..d1d7a7c32 100644 --- a/apps/web/src/panels/WelcomePanel.tsx +++ b/apps/web/src/panels/WelcomePanel.tsx @@ -136,7 +136,6 @@ export function WelcomePanel() { onClick={() => setDialog({ projectId: project.id, prompt: "" })} /> {projectFolderCard(project.id)} - {demoCard()} ) : ( <> @@ -162,7 +161,6 @@ export function WelcomePanel() { onClick={() => setDialog({ projectId: project.id, prompt: "" })} /> {projectFolderCard(project.id)} - {demoCard()} )}
    diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 314b5b92e..74b93ba88 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -1,7 +1,7 @@ import { ChevronRight, GitBranch, Settings } from "lucide-react"; import { useEffect, useRef } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../components/ui/resizable"; -import { OnboardingCoach } from "../onboarding"; +import { OnboardingDemo } from "../onboarding"; import { ProjectTree } from "../panels/ProjectTree"; import { SettingsDialog } from "../panels/SettingsDialog"; import { Toaster } from "../panels/Toaster"; @@ -218,7 +218,7 @@ export function Shell() {
    )} - +
    ); } diff --git a/apps/web/src/store/appStore.ts b/apps/web/src/store/appStore.ts index 6cd9446a4..16717d743 100644 --- a/apps/web/src/store/appStore.ts +++ b/apps/web/src/store/appStore.ts @@ -262,14 +262,18 @@ export interface ChatLocationRequest { export type OnboardingFlow = "demo"; +export type OnboardingStage = "welcome" | "picker" | "live"; + export interface OnboardingState { flow: OnboardingFlow | null; + stage: OnboardingStage | null; demoProjectId: string | null; dismissed: boolean; } export const NO_ONBOARDING: OnboardingState = { flow: null, + stage: null, demoProjectId: null, dismissed: false, }; @@ -827,6 +831,8 @@ interface AppState { applyReviewChanged: (payload: ReviewChangedPayload) => void; pushToast: (toast: Omit) => string; dismissToast: (id: string) => void; + startDemoTour: () => void; + setDemoStage: (stage: OnboardingStage) => void; startOnboarding: (demoProjectId: string) => void; dismissOnboarding: () => void; resetOnboarding: () => void; @@ -2779,8 +2785,11 @@ export const useAppStore = create((set, get) => ({ set((s) => s.toasts.some((t) => t.id === id) ? { toasts: s.toasts.filter((t) => t.id !== id) } : {}, ), + startDemoTour: () => + set({ onboarding: { flow: "demo", stage: "welcome", demoProjectId: null, dismissed: false } }), + setDemoStage: (stage) => set((s) => ({ onboarding: { ...s.onboarding, stage } })), startOnboarding: (demoProjectId) => - set({ onboarding: { flow: "demo", demoProjectId, dismissed: false } }), + set({ onboarding: { flow: "demo", stage: "live", demoProjectId, dismissed: false } }), dismissOnboarding: () => set((s) => s.onboarding.dismissed ? {} : { onboarding: { ...s.onboarding, dismissed: true } }, diff --git a/apps/web/src/store/onboarding.test.ts b/apps/web/src/store/onboarding.test.ts index fa336e811..0ad141bb7 100644 --- a/apps/web/src/store/onboarding.test.ts +++ b/apps/web/src/store/onboarding.test.ts @@ -39,7 +39,12 @@ function withUserTurn(): SessionRuntime { }; } -const onboarding: OnboardingState = { flow: "demo", demoProjectId: DEMO, dismissed: false }; +const onboarding: OnboardingState = { + flow: "demo", + stage: "live", + demoProjectId: DEMO, + dismissed: false, +}; function baseState(workspaces: Workspace[]) { return { @@ -54,6 +59,7 @@ function baseState(workspaces: Workspace[]) { test("selectOnboardingActive: true only for an armed, undismissed demo flow", () => { expect(selectOnboardingActive({ onboarding })).toBe(true); expect(selectOnboardingActive({ onboarding: { ...onboarding, dismissed: true } })).toBe(false); + expect(selectOnboardingActive({ onboarding: { ...onboarding, stage: "welcome" } })).toBe(false); expect(selectOnboardingActive({ onboarding: NO_ONBOARDING })).toBe(false); }); diff --git a/apps/web/src/store/selectors.ts b/apps/web/src/store/selectors.ts index ae847e365..50436e837 100644 --- a/apps/web/src/store/selectors.ts +++ b/apps/web/src/store/selectors.ts @@ -439,6 +439,7 @@ interface OnboardingDomainState { export function selectOnboardingActive(state: { onboarding: OnboardingState }): boolean { return ( state.onboarding.flow === "demo" && + state.onboarding.stage === "live" && !state.onboarding.dismissed && state.onboarding.demoProjectId !== null ); diff --git a/apps/web/src/styles/colors.json b/apps/web/src/styles/colors.json index 8f056b77c..1bcbee19c 100644 --- a/apps/web/src/styles/colors.json +++ b/apps/web/src/styles/colors.json @@ -1,7 +1,7 @@ { "$schema": "./colors.schema.json", "metadata": { - "version": "1.1.0", + "version": "1.2.0", "note": "The semantic colour layer. Palettes live in themes/bundled/*.theme.json; this file says what each palette entry is FOR, and is the only place a derivation is written. A role's `from` names a theme manifest key; the CSS variable that key writes to is derived (kebab-case), not tabulated. See COLOR.md." }, @@ -10,7 +10,8 @@ "wash": 12, "soft": 20, "muted": 40, - "strong": 60 + "strong": 60, + "veil": 70 }, "roles": { @@ -35,6 +36,12 @@ "publish": true, "note": "the app surface, and with it the opened-document canvas — a document reads as part of the workspace (Monaco's EDITOR_THEME, the markdown/spec preview, the chat column, the tab strip)" }, + "container-workspace-overlay": { + "from": "background", + "alpha": "veil", + "publish": true, + "note": "the onboarding spotlight scrim: the workspace surface at the `veil` (70%) alpha step, dimming the viewport while a coach mark spotlights one target" + }, "container-sidebar-bg": { "from": "sidebar", "publish": true }, "container-terminal-bg": { "from": "sidebar", "publish": true }, "container-header-bg": { "from": "header", "publish": true }, diff --git a/apps/web/src/styles/generated/colors.css b/apps/web/src/styles/generated/colors.css index 3c6d00620..37567d933 100644 --- a/apps/web/src/styles/generated/colors.css +++ b/apps/web/src/styles/generated/colors.css @@ -1,5 +1,5 @@ /* - * GENERATED — do not edit. Source: `src/styles/colors.json` (v1.1.0). + * GENERATED — do not edit. Source: `src/styles/colors.json` (v1.2.0). * Regenerate with `bun run colors:generate`; `colors:check` fails when this file is stale. * * The semantic roles, then the Tailwind utility map. The palette they read is written to the @@ -14,6 +14,7 @@ --text-on-primary: var(--on-accent); --text-link: var(--info); /* global.css `a {}` is the only consumer; no component styles a bare link */ --container-workspace-bg: var(--background); /* the app surface, and with it the opened-document canvas — a document reads as part of the workspace (Monaco's EDITOR_THEME, the markdown/spec preview, the chat column, the tab strip) */ + --container-workspace-overlay: color-mix(in srgb, var(--background) 70%, transparent); /* the onboarding spotlight scrim: the workspace surface at the `veil` (70%) alpha step, dimming the viewport while a coach mark spotlights one target */ --container-sidebar-bg: var(--sidebar); --container-terminal-bg: var(--sidebar); --container-header-bg: var(--header); @@ -83,6 +84,7 @@ --color-text-disabled: var(--text-disabled); --color-text-on-primary: var(--text-on-primary); --color-container-workspace-bg: var(--container-workspace-bg); + --color-container-workspace-overlay: var(--container-workspace-overlay); --color-container-sidebar-bg: var(--container-sidebar-bg); --color-container-terminal-bg: var(--container-terminal-bg); --color-container-header-bg: var(--container-header-bg); diff --git a/e2e/onboarding-demo.spec.ts b/e2e/onboarding-demo.spec.ts index d363448fa..9d7a06e7b 100644 --- a/e2e/onboarding-demo.spec.ts +++ b/e2e/onboarding-demo.spec.ts @@ -8,54 +8,47 @@ test.beforeEach(() => { rmSync(join(E2E_DATA_DIR, "demo"), { recursive: true, force: true }); }); -test("enter the demo, the coach guides creating two workspaces, then reset replays it", async ({ +test("the left-panel launcher runs the simulated empty-state flow into the live demo", async ({ page, }) => { await openAppFresh(page); - const demoCard = page.getByTestId("welcome-demo"); - await expect(demoCard).toBeVisible(); - await expect(page.getByTestId("welcome-cta")).toContainText("Open project"); - await demoCard.click(); + await page.getByTestId("onboarding-launch").click(); + + const coach = page.getByTestId("onboarding-coach"); + await expect(coach).toContainText("Step 1 of 4"); + await expect(coach).toContainText("Open a project"); + await page.getByTestId("demo-open-project").click(); + + await expect(coach).toContainText("Choose your project folder"); + await page.getByTestId("demo-folder-todo").click(); await expect(page.getByTestId("project-item")).toHaveCount(1); await expect(page.getByTestId("welcome-title")).toHaveText("To Do App"); - await expect(page.getByTestId("welcome-cta")).toContainText("Start building"); + await expect(coach).toContainText("Step 2 of 4"); - const coach = page.getByTestId("onboarding-coach"); - await expect(coach).toContainText("Step 1 of 3"); await page.getByTestId("welcome-cta").click(); await expect(page.getByTestId("new-workspace-dialog")).toBeVisible(); await page.getByTestId("create-workspace").click(); await expect(worktreeRows(page)).toHaveCount(1); - await expect(coach).toContainText("Step 1 of 3"); + await expect(coach).toContainText("Step 2 of 4"); await createWorkspaceViaDialog(page); await expect(worktreeRows(page)).toHaveCount(2); - await expect(coach).toContainText("Step 2 of 3"); - - await page.getByTestId("project-item").first().getByTestId("project-name").click(); - await expect(page.getByTestId("welcome")).toBeVisible(); - await page.getByTestId("welcome-reset-demo").click(); + await expect(coach).toContainText("Step 3 of 4"); - await expect(page.getByTestId("project-item")).toHaveCount(0); - await expect(page.getByTestId("welcome-demo")).toBeVisible(); + await page.getByTestId("onboarding-exit").click(); await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); }); -test("skipping the tour hides the coach but keeps the demo project", async ({ page }) => { +test("the empty-state Welcome card starts the demo directly at the live coach", async ({ page }) => { await openAppFresh(page); await page.getByTestId("welcome-demo").click(); - const coach = page.getByTestId("onboarding-coach"); - await expect(coach).toContainText("Step 1 of 3"); - await page.getByTestId("onboarding-skip").click(); - - await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); await expect(page.getByTestId("project-item")).toHaveCount(1); + await expect(page.getByTestId("onboarding-coach")).toContainText("Step 2 of 4"); - await page.getByTestId("project-item").first().getByTestId("project-name").click(); - await page.getByTestId("welcome-reset-demo").click(); - await expect(page.getByTestId("project-item")).toHaveCount(0); + await page.getByTestId("onboarding-exit").click(); + await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); }); From 8226240ec7cb0f557e39b963d45fbe39155c76e6 Mon Sep 17 00:00:00 2001 From: "Julia.Shilova" Date: Mon, 24 Aug 2026 22:27:23 +0300 Subject: [PATCH 13/23] feat(web): self-contained mocked onboarding simulation Replaces the onboarding entry behavior with a fully self-contained, frontend-only interactive simulation in a ~90vw x 90vh modal card (OnboardingSimulation). It renders a faithful but mocked ThinkRail (header / left panel / center) driven entirely by local React state: scripted flow of open project -> fake folder picker (to-do-app) -> create two isolated workspaces -> run agent 1 -> run agent 2 in parallel (first shown done while the second works) -> completion. Non-dismissible coach marks with a card-scoped container-workspace-overlay spotlight + arrow; only the current target is interactive. Touches NO domain state: no demo.ensure/reset, no Projects/Workspaces/ Sessions, no pi, no OS picker, no persistence. Launcher + Welcome card now flip a top-level store.demoOpen flag (openDemo/closeDemo). The prior real-domain onboarding coach + client demo orchestration are retained but unwired (dormant) for easy iteration; the server bundled-demo capability is untouched. Specs + e2e updated (e2e asserts the real registry stays empty throughout). --- apps/web/SPEC.md | 4 +- .../web/src/onboarding/OnboardingLauncher.tsx | 4 +- .../src/onboarding/OnboardingSimulation.tsx | 574 ++++++++++++++++++ apps/web/src/onboarding/SPEC.md | 132 ++-- apps/web/src/onboarding/index.ts | 1 + apps/web/src/panels/SPEC.md | 21 +- apps/web/src/panels/WelcomePanel.tsx | 19 +- apps/web/src/shell/Shell.tsx | 4 +- apps/web/src/store/appStore.ts | 6 + e2e/onboarding-demo.spec.ts | 64 +- 10 files changed, 684 insertions(+), 145 deletions(-) create mode 100644 apps/web/src/onboarding/OnboardingSimulation.tsx diff --git a/apps/web/SPEC.md b/apps/web/SPEC.md index c1217bb07..a932a9d9c 100644 --- a/apps/web/SPEC.md +++ b/apps/web/SPEC.md @@ -59,8 +59,8 @@ screen, not a blank root). ### Dependency graph - `navigation` → `store`, `transport`, `contracts` (type-only); neither dependency imports it, and `main.tsx` initializes the integration -- `shell` → child `shell/layout`, `panels`, `onboarding` (mounts `OnboardingCoach` beside `Toaster`), `chat` (app-integration render/hydration only), `store`, `transport`, `contracts` (type-only), `components/ui`, `components` (`ErrorBoundary` around each mounted region), `constants`, `lib` (platform shortcut semantics), `themes` (the single owner of the atomic `applyTheme` DOM effect, driven by `store.theme`) -- `onboarding` → `store`, `transport`, `components/ui`, `lib`, `contracts`; mounted by `shell`, initialized in `main.tsx`; `panels/WelcomePanel` calls its `startDemo`/`resetDemo` (one-way panels→onboarding edge, no cycle) +- `shell` → child `shell/layout`, `panels`, `onboarding` (mounts `OnboardingSimulation` beside `Toaster`), `chat` (app-integration render/hydration only), `store`, `transport`, `contracts` (type-only), `components/ui`, `components` (`ErrorBoundary` around each mounted region), `constants`, `lib` (platform shortcut semantics), `themes` (the single owner of the atomic `applyTheme` DOM effect, driven by `store.theme`) +- `onboarding` → `store`, `components/ui`, `constants`, `lib`, `contracts`; mounted by `shell`; `panels` (`WelcomePanel` + `ProjectTree` footer) call `openDemo` (one-way panels→onboarding edge, no cycle). The active `OnboardingSimulation` is fully mocked (no `transport`); the retained-but-dormant real-domain coach still carries the `transport`/`demo` edges (see [[submodule-web-onboarding]]) - `shell/layout` → `contracts` (types only), `lib` (attention/id primitives), and React / `react-resizable-panels` / `@dnd-kit/core`; the parent injects store state, commit callbacks, and feature renderers, so the child has no feature-module runtime edge - `panels` → `store`, `transport`, `components/ui`, `components` (`ErrorBoundary` for feature bodies), `lib`, `contracts`, `constants` (`WelcomePanel`'s wordmark), `chat` (`NewWorkspaceDialog` eagerly reuses `chat/ModelSelector`+`ThinkingSelector`+`useModelCatalog` — these are shiki-free, so the eager import stays split-safe; `TemplatesSettings` reuses `chat/TemplateEditorDialog` for its New/Edit flows — see `panels/SPEC.md`'s `TemplatesSettings` paragraph), `auth` (`ProvidersSettings` mounts `auth/LoginDialog`), `themes` (`AppearanceSettings` consumes the live catalog; code surfaces consume generic theme variables/syntax mapping) - `chat` → `contracts` (pi message types, **type-only**), `components/ui`, `lib`; `store` + `transport` diff --git a/apps/web/src/onboarding/OnboardingLauncher.tsx b/apps/web/src/onboarding/OnboardingLauncher.tsx index dfa36408a..df6f0b6a5 100644 --- a/apps/web/src/onboarding/OnboardingLauncher.tsx +++ b/apps/web/src/onboarding/OnboardingLauncher.tsx @@ -2,14 +2,14 @@ import { GraduationCap } from "lucide-react"; import { useAppStore } from "../store"; export function OnboardingLauncher() { - const startDemoTour = useAppStore((s) => s.startDemoTour); + const openDemo = useAppStore((s) => s.openDemo); return ( +
    +
      +
    • + + + Default + +
    • + {workspaces.map((name, index) => ( +
    • + +
    • + ))} +
    + {step === "agent2" || step === "agent2-switch" ? ( + + Both workspaces keep their own agent session — switching tabs never stops them. + + ) : null} + + ) : ( + No project open + )} + + ); +} + +function SimCenter({ + step, + activeWs, + messages, + status, + draft, + onDraft, + onOpenProject, + onPickFolder, + onSend, +}: { + step: Step; + activeWs: number; + messages: Record; + status: Record; + draft: string; + onDraft: (value: string) => void; + onOpenProject: () => void; + onPickFolder: () => void; + onSend: () => void; +}) { + if (step === "open") { + return ( +
    +

    {PRODUCT_NAME}

    +
    + +
    +
    + ); + } + if (step === "picker") { + return ( +
    +
    +
    + + Home + / + Projects +
    +
      +
    • + +
    • +
    +
    +
    + ); + } + if (step === "ws1-create" || step === "ws2-create") { + return ( +
    +

    To Do App

    +

    + Create a workspace for each task. Every workspace is an isolated git worktree on its own + branch, so two features never collide. +

    +
    + ); + } + const rows = messages[activeWs] ?? []; + return ( +
    +
    + {rows.length === 0 ? ( +

    + Start the agent below to build this task. +

    + ) : ( + rows.map((msg) => + msg.role === "user" ? ( +
    + {msg.text} +
    + ) : msg.role === "working" ? ( +
    + + Working… +
    + ) : ( +
    + {msg.text} +
    + ), + ) + )} +
    +
    +
    +