Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
76805a9
build(cli): bundle To Do App demo template + embed it in the binary
Aug 24, 2026
b2e346c
feat(server): demo module — lazily materialize the To Do App demo as …
Aug 24, 2026
5383719
feat(server): wire demo.ensure / demo.reset
Aug 24, 2026
d53fb9c
feat(server): ship a small SPEC.md in the To Do App demo template
Aug 24, 2026
f11ac13
feat(web): onboarding store slice + persistence
Aug 24, 2026
77fedea
feat(web): onboarding coach — contextual coach-marks for the demo tour
Aug 24, 2026
857681b
feat(web): Welcome demo entry card + Reset demo button
Aug 24, 2026
8587457
fix(server): demo.reset drops all workspace records incl Default
Aug 24, 2026
3650543
test(e2e): onboarding demo — entry, two-workspace coaching, reset, skip
Aug 24, 2026
1f0c01c
feat(server): display the demo project as "To Do App"
Aug 24, 2026
4b4bde3
chore(web): TEMP — show demo card in project Welcome for design review
Aug 24, 2026
e073f8b
feat(web): manual-launch demo tour from a simulated empty state
Aug 24, 2026
8226240
feat(web): self-contained mocked onboarding simulation
Aug 24, 2026
6e57f48
feat(web): refine the mocked onboarding demo
Aug 25, 2026
9258dfd
feat(web): mocked Git-readiness moment in the onboarding intro
Aug 25, 2026
ed094fb
feat(web): soft primary brand glow behind the demo project card
Aug 25, 2026
0d6db0e
feat(web): onboarding uses the real composer + real Create-workspace …
Aug 25, 2026
4e589d0
feat(web): Finder-style three-column folder picker in the demo
Aug 25, 2026
3c28523
feat(web): onboarding prefills create-dialog tasks, inverts coach marks
Aug 25, 2026
58c3b8f
feat(web): light/inverse surface for the onboarding folder picker
Aug 25, 2026
80b0a74
feat(web): parallel-agents payoff in the onboarding demo
Aug 25, 2026
7040e32
feat(web): share one intro/outro layout for the demo onboarding
Aug 28, 2026
c0d2e11
feat(web): simplify demo intro layout and Git readiness into one acti…
Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<host|--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
Expand All @@ -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 `<dir>.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
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/scripts/build-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -105,13 +107,26 @@ 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));

mkdirSync(outDir, { recursive: true });
generateWebManifest();
generateBundledExtensions();
generateRuntimeManifest();
generateDemoManifest();
try {
const proc = Bun.spawnSync(
[
Expand All @@ -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}`);
3 changes: 3 additions & 0 deletions apps/cli/src/compiled-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/demo-assets.generated.d.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion apps/web/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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 `OnboardingSimulation` beside `Toaster`, injecting the real `panels/NewWorkspaceDialog` in `preview` mode via its `renderCreateDialog` prop so `onboarding` never imports `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`)
- `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`
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;

function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow className={cn("fill-container-elevated-bg", className)} {...props} />
);
}

function PopoverContent({
className,
align = "center",
Expand All @@ -30,4 +39,4 @@ function PopoverContent({
);
}

export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
3 changes: 3 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
--spacing-md: var(--space-md);
--spacing-lg: var(--space-lg);
--spacing-xl: var(--space-xl);
--spacing-xxl: var(--space-xxl);
--spacing-xxxl: var(--space-xxxl);
--spacing-xxxxl: var(--space-xxxxl);
--spacing-panel-header-row: var(--panel-header-row-height);

/* A block appearing in place (e.g. the ask-question card's atomic reveal once its args are final). */
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,6 +13,7 @@ initializeBundledThemes();
applyTheme(readThemeHint());
initTransport();
initProjectExpansionPersistence();
initOnboardingPersistence();
initNavigation();

const root = document.getElementById("root");
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/onboarding/OnboardingCoach.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useShallow } from "zustand/react/shallow";
import { Button } from "../components/ui/button";
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 <DoneCard />;
return <StepSpotlight coach={coach} />;
}

function StepSpotlight({ coach }: { coach: CoachStep }) {
const setChatDraft = useAppStore((s) => s.setChatDraft);
return (
<Spotlight selector={coach.selector}>
<CoachBody
step={coach.index}
title={coach.title}
body={coach.body}
action={
coach.insertPrompt && coach.sessionId ? (
<Button
variant="outline"
size="sm"
data-testid="onboarding-insert-prompt"
onClick={() => setChatDraft(coach.sessionId as string, coach.insertPrompt as string)}
>
Insert prompt
</Button>
) : undefined
}
/>
</Spotlight>
);
}

function DoneCard() {
const resetOnboarding = useAppStore((s) => s.resetOnboarding);
return (
<div className="pointer-events-none fixed inset-0 z-50 flex items-end justify-center pb-xl">
<div
data-testid="onboarding-coach"
className="pointer-events-auto w-[320px] rounded-[var(--radius-md)] border border-border-default bg-container-elevated-bg p-lg text-center shadow-[var(--shadow-md)]"
>
<p className="tr-title-card text-text-default">You're all set</p>
<p className="mt-xs text-text-muted tr-text-metadata leading-snug">
You created two isolated workspaces and ran agents in parallel — that's the ThinkRail
loop.
</p>
<div className="mt-md flex items-center justify-center gap-sm">
<Button
variant="outline"
size="sm"
data-testid="onboarding-reset"
onClick={() => void resetDemo()}
>
Reset demo
</Button>
<Button size="sm" data-testid="onboarding-done" onClick={() => resetOnboarding()}>
Done
</Button>
</div>
</div>
</div>
);
}
123 changes: 123 additions & 0 deletions apps/web/src/onboarding/OnboardingDemo.tsx
Original file line number Diff line number Diff line change
@@ -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" ? <DemoEmptyWelcome /> : null}
{stage === "picker" ? <DemoFolderPicker /> : null}
{stage === "live" ? <OnboardingCoach /> : null}
<button
type="button"
data-testid="onboarding-exit"
onClick={() => resetOnboarding()}
className="fixed top-md right-md z-50 inline-flex items-center gap-xs rounded-[var(--radius-sm)] border border-border-default bg-container-elevated-bg px-sm py-xs text-text-muted tr-text-metadata shadow-[var(--shadow-sm)] outline-none transition-colors hover:text-text-default focus-visible:ring-2 focus-visible:ring-primary"
>
<X className="size-3.5" />
Exit demo
</button>
</>
);
}

function DemoScaffold({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-30 flex flex-col bg-container-workspace-bg">
<div className="flex h-12 shrink-0 items-center border-border-default border-b bg-container-header-bg px-lg">
<span className="tr-title-card text-primary">{PRODUCT_NAME}</span>
</div>
<div className="flex min-h-0 flex-1">
<aside className="w-[18%] min-w-[160px] border-border-default border-r bg-container-sidebar-bg p-md">
<span className="tr-text-eyebrow text-text-muted">Projects</span>
</aside>
<div className="flex min-h-0 flex-1 items-center justify-center bg-container-content-bg p-xl">
{children}
</div>
</div>
</div>
);
}

function DemoEmptyWelcome() {
const setDemoStage = useAppStore((s) => s.setDemoStage);
return (
<>
<DemoScaffold>
<div className="flex flex-col items-center text-center">
<h1 className="tr-brand-hero text-primary">{PRODUCT_NAME}</h1>
<div className="mt-xl">
<button
type="button"
data-onboarding="demo-open"
data-testid="demo-open-project"
onClick={() => setDemoStage("picker")}
className="relative flex h-[150px] w-[220px] flex-col items-start justify-between rounded-[var(--radius-sm)] border border-primary-muted bg-clip-padding bg-primary-subtle p-lg text-left transition-colors hover:bg-primary-soft"
>
<span className="flex size-9 items-center justify-center rounded-[var(--radius-sm)] bg-primary text-text-on-primary">
<FolderOpen className="size-4" />
</span>
<span className="w-full">
<span className="block tr-title-card text-text-default">Open project</span>
<span className="mt-0.5 block text-text-muted tr-text-metadata leading-snug">
Choose a local git repository to work in.
</span>
</span>
</button>
</div>
</div>
</DemoScaffold>
<Spotlight selector='[data-onboarding="demo-open"]' side="right" align="center">
<CoachBody
step={1}
title="Open a project"
body="Choose a project folder from your computer."
/>
</Spotlight>
</>
);
}

function DemoFolderPicker() {
return (
<>
<DemoScaffold>
<div className="w-[520px] overflow-hidden rounded-[var(--radius-lg)] border border-border-default bg-container-elevated-bg shadow-[var(--shadow-lg)]">
<div className="flex items-center gap-xs border-border-default border-b px-lg py-md tr-text-metadata text-text-muted">
<Folder className="size-3.5" />
<span>Home</span>
<span>/</span>
<span>Projects</span>
</div>
<ul className="p-sm">
<li>
<button
type="button"
data-onboarding="demo-folder"
data-testid="demo-folder-todo"
onClick={() => void startDemo()}
className="flex w-full items-center gap-sm rounded-[var(--radius-sm)] px-md py-sm text-left tr-text-ui text-text-default transition-colors hover:bg-control-bg-hovered"
>
<Folder className="size-4 text-primary" />
<span>to-do-app</span>
</button>
</li>
</ul>
</div>
</DemoScaffold>
<Spotlight selector='[data-onboarding="demo-folder"]' side="right" align="center">
<CoachBody
step={1}
title="Choose your project folder"
body="Select the To Do App folder to open it in ThinkRail."
/>
</Spotlight>
</>
);
}
19 changes: 19 additions & 0 deletions apps/web/src/onboarding/OnboardingLauncher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { GraduationCap } from "lucide-react";
import { useAppStore } from "../store";

export function OnboardingLauncher() {
const openDemo = useAppStore((s) => s.openDemo);
return (
<button
type="button"
data-testid="onboarding-launch"
aria-label="Start the onboarding demo"
title="Start the onboarding demo"
onClick={() => openDemo()}
className="flex h-7 items-center gap-sm rounded-[var(--radius-sm)] px-sm text-text-muted tr-text-ui outline-none transition-colors hover:bg-control-bg-hovered hover:text-text-default focus-visible:ring-2 focus-visible:ring-primary"
>
<GraduationCap className="size-4 shrink-0" />
<span className="truncate">Onboarding demo</span>
</button>
);
}
Loading