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/apps/web/SPEC.md b/apps/web/SPEC.md index c0624c563..f31834664 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 `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` 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/index.css b/apps/web/src/index.css index 2e86b57a3..151659113 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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). */ 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/OnboardingCoach.tsx b/apps/web/src/onboarding/OnboardingCoach.tsx new file mode 100644 index 000000000..ed6827ac1 --- /dev/null +++ b/apps/web/src/onboarding/OnboardingCoach.tsx @@ -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 ; + return ; +} + +function StepSpotlight({ coach }: { coach: CoachStep }) { + const setChatDraft = useAppStore((s) => s.setChatDraft); + return ( + + setChatDraft(coach.sessionId as string, coach.insertPrompt as string)} + > + Insert prompt + + ) : undefined + } + /> + + ); +} + +function DoneCard() { + const resetOnboarding = useAppStore((s) => s.resetOnboarding); + 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/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..df6f0b6a5 --- /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 openDemo = useAppStore((s) => s.openDemo); + return ( + + ); +} diff --git a/apps/web/src/onboarding/OnboardingSimulation.tsx b/apps/web/src/onboarding/OnboardingSimulation.tsx new file mode 100644 index 000000000..f13744274 --- /dev/null +++ b/apps/web/src/onboarding/OnboardingSimulation.tsx @@ -0,0 +1,1092 @@ +import { + Check, + ChevronRight, + FileText, + Folder, + FolderOpen, + GitBranch, + House, + Loader2, + type LucideIcon, + Plus, + SquareTerminal, + X, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "../components/ui/button"; +import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from "../components/ui/popover"; +import { PRODUCT_NAME } from "../constants/branding"; +import { cn } from "../lib"; +import { useAppStore } from "../store"; +import { useTargetRect } from "./anchor"; + +const TASK_1 = "Implement a search feature in my To Do app."; +const TASK_2 = "Add filtering by tags so I can quickly show tasks with a specific tag."; +const WS_NAMES = ["Search feature", "Tag filtering"]; + +const QUESTION = "Where should tag filters appear?"; +const QUESTION_OPTIONS = ["Above the task list", "In the sidebar", "Next to the search field"]; + +type Activity = + | { id: string; kind: "user" | "note" | "result"; text: string } + | { id: string; kind: "tool"; text: string } + | { id: string; kind: "working"; text: string } + | { id: string; kind: "changes"; text: string }; + +const uid = () => crypto.randomUUID(); + +function ws1Working(): Activity[] { + return [ + { id: uid(), kind: "user", text: TASK_1 }, + { id: uid(), kind: "tool", text: "Read index.html" }, + { id: uid(), kind: "tool", text: "Read src/app.js" }, + { id: uid(), kind: "note", text: "Plan: add a search box that filters tasks as you type." }, + { id: uid(), kind: "working", text: "Implementing search…" }, + ]; +} + +function ws1Done(): Activity[] { + return [ + { id: uid(), kind: "user", text: TASK_1 }, + { id: uid(), kind: "tool", text: "Edit src/app.js" }, + { id: uid(), kind: "tool", text: "Edit index.html" }, + { + id: uid(), + kind: "result", + text: "Search is live — a searchTasks() filter wired to a new input; tasks narrow as you type.", + }, + { id: uid(), kind: "changes", text: "2 files changed · +38 −4" }, + ]; +} + +function ws2Thinking(): Activity[] { + return [ + { id: uid(), kind: "user", text: TASK_2 }, + { id: uid(), kind: "tool", text: "Read src/app.js" }, + { + id: uid(), + kind: "note", + text: "Thinking about where the tag filters should live in the UI…", + }, + ]; +} + +function ws2Resume(choice: string): Activity[] { + return [ + { id: uid(), kind: "note", text: `Got it — placing the filters ${choice.toLowerCase()}.` }, + { id: uid(), kind: "tool", text: "Edit src/app.js" }, + { id: uid(), kind: "working", text: "Adding tag parsing and the filter row…" }, + ]; +} + +type Step = + | "intro" + | "open" + | "picker" + | "ws1-create" + | "ws1-working" + | "ws2-create" + | "ws2-working" + | "ws2-question" + | "ws2-resume" + | "ws1-done" + | "final"; + +const STEP_ORDER: Step[] = [ + "intro", + "open", + "picker", + "ws1-create", + "ws1-working", + "ws2-create", + "ws2-working", + "ws2-question", + "ws2-resume", + "ws1-done", + "final", +]; + +type WsStatus = "idle" | "working" | "done"; + +type CoachInfo = { + selector: string; + side: "top" | "right"; + scope: "card" | "viewport"; + title: string; + body: string; +}; + +function activeCoach(step: Step, dialogOpen: boolean, dialogReady: boolean): CoachInfo | null { + switch (step) { + case "open": + return { + selector: '[data-sim="open-project"]', + side: "top", + scope: "card", + title: "Open a project", + body: "Choose a project folder from your computer.", + }; + case "picker": + return { + selector: '[data-sim="folder"]', + side: "top", + scope: "card", + title: "Choose your project folder", + body: "Select the To Do App folder to open it in ThinkRail.", + }; + case "ws1-create": + case "ws2-create": { + if (!dialogOpen) + return { + selector: '[data-sim="rail-add"]', + side: "right", + scope: "card", + title: step === "ws1-create" ? "Create a workspace" : "Create a second workspace", + body: "Each task runs in its own isolated worktree and branch. Open the New workspace dialog.", + }; + if (!dialogReady) return null; + return { + selector: '[data-testid="create-workspace"]', + side: "right", + scope: "viewport", + title: "Create the workspace", + body: "The task is ready. Create the workspace to start it on its own branch.", + }; + } + case "ws1-working": + return { + selector: '[data-sim="rail-add"]', + side: "right", + scope: "card", + title: "Now start a second task", + body: "Your first agent keeps working here. Open a second workspace for the next task.", + }; + case "ws2-question": + return { + selector: '[data-sim="question"]', + side: "right", + scope: "card", + title: "Give the agent feedback", + body: "Choose one of the suggestions or write your own.", + }; + case "ws2-resume": + return { + selector: '[data-sim="ws-0"]', + side: "right", + scope: "card", + title: "Your agents work in parallel", + body: "Your first task kept running in another workspace. Check its progress.", + }; + default: + return null; + } +} + +export type CreateDialogArgs = { + onCreate: () => void; + onClose: () => void; + onReady: () => void; + prompt: string; +}; + +export function OnboardingSimulation({ + renderCreateDialog, +}: { + renderCreateDialog: (args: CreateDialogArgs) => ReactNode; +}) { + const open = useAppStore((s) => s.demoOpen); + if (!open) return null; + return ; +} + +function useElementRect(el: HTMLElement | null): DOMRect | null { + const [rect, setRect] = useState(null); + useEffect(() => { + if (!el) return; + let frame = 0; + const measure = () => { + setRect(el.getBoundingClientRect()); + frame = requestAnimationFrame(measure); + }; + measure(); + return () => cancelAnimationFrame(frame); + }, [el]); + return rect; +} + +function Simulation({ + renderCreateDialog, +}: { + renderCreateDialog: (args: CreateDialogArgs) => ReactNode; +}) { + const closeDemo = useAppStore((s) => s.closeDemo); + const [cardEl, setCardEl] = useState(null); + const cardRect = useElementRect(cardEl); + const [step, setStep] = useState("intro"); + const startTour = useCallback(() => setStep("open"), []); + const [workspaces, setWorkspaces] = useState([]); + const [activeWs, setActiveWs] = useState(0); + const [messages, setMessages] = useState>({}); + const [status, setStatus] = useState>({}); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogReady, setDialogReady] = useState(false); + const timers = useRef[]>([]); + const after = useCallback((ms: number, fn: () => void) => { + timers.current.push(setTimeout(fn, ms)); + }, []); + useEffect(() => { + const list = timers.current; + return () => list.forEach(clearTimeout); + }, []); + + const projectOpen = step !== "intro" && step !== "open" && step !== "picker"; + const coach = activeCoach(step, dialogOpen, dialogReady); + const progress = STEP_ORDER.indexOf(step) / (STEP_ORDER.length - 1); + + const onRailAdd = () => { + if (step === "ws1-working") setStep("ws2-create"); + if (step === "ws1-create" || step === "ws2-create" || step === "ws1-working") { + setDialogReady(false); + setDialogOpen(true); + } + }; + + const onPreviewCreate = () => { + if (step === "ws1-create") { + setWorkspaces([WS_NAMES[0] as string]); + setActiveWs(0); + setStatus({ 0: "working" }); + setMessages({ 0: ws1Working() }); + setDialogOpen(false); + setStep("ws1-working"); + } else if (step === "ws2-create") { + setWorkspaces([WS_NAMES[0] as string, WS_NAMES[1] as string]); + setActiveWs(1); + setStatus({ 0: "working", 1: "working" }); + setMessages((m) => ({ ...m, 1: ws2Thinking() })); + setDialogOpen(false); + setStep("ws2-working"); + after(1600, () => setStep("ws2-question")); + } + }; + + const onAnswer = (choice: string) => { + if (step !== "ws2-question") return; + setMessages((m) => ({ + ...m, + 1: [...(m[1] ?? []), { id: uid(), kind: "user", text: choice }, ...ws2Resume(choice)], + })); + setStep("ws2-resume"); + }; + + const onWsClick = (index: number) => { + if (step === "ws2-resume" && index === 0) { + setActiveWs(0); + setStatus((s) => ({ ...s, 0: "done" })); + setMessages((m) => ({ ...m, 0: ws1Done() })); + setStep("ws1-done"); + after(1900, () => setStep("final")); + } + }; + + return ( +
+
+ +
+ {PRODUCT_NAME} + {projectOpen ? ( + + To Do App + {workspaces[activeWs] ? ` · ${workspaces[activeWs]}` : ""} + + ) : null} +
+ +
+ + setStep("picker")} + onPickFolder={() => setStep("ws1-create")} + onAnswer={onAnswer} + /> +
+ + {step === "intro" ? : null} + {step === "final" ? closeDemo()} /> : null} + {coach?.scope === "card" ? ( + + ) : null} + {coach?.scope === "viewport" ? ( + + ) : null} + {dialogOpen + ? renderCreateDialog({ + onCreate: onPreviewCreate, + onClose: () => setDialogOpen(false), + onReady: () => setDialogReady(true), + prompt: step === "ws1-create" ? TASK_1 : TASK_2, + }) + : null} + +
+
+
+
+
+ ); +} + +function SimLeftPanel({ + projectOpen, + workspaces, + activeWs, + status, + step, + onRailAdd, + onWsClick, +}: { + projectOpen: boolean; + workspaces: string[]; + activeWs: number; + status: Record; + step: Step; + onRailAdd: () => void; + onWsClick: (index: number) => void; +}) { + const parallel = step === "ws2-working" || step === "ws2-question" || step === "ws2-resume"; + return ( + + ); +} + +function SimMain({ + step, + rows, + onOpenProject, + onPickFolder, + onAnswer, +}: { + step: Step; + rows: Activity[]; + onOpenProject: () => void; + onPickFolder: () => void; + onAnswer: (choice: string) => void; +}) { + if (step === "open") { + return ( +
+

{PRODUCT_NAME}

+
+
+ +
+
+ ); + } + if (step === "picker") { + return ; + } + 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. +

+
+ ); + } + return ( +
+
+ + +
+ +
+ ); +} + +function AgentChat({ + rows, + question, + onAnswer, +}: { + rows: Activity[]; + question: boolean; + onAnswer: (choice: string) => void; +}) { + return ( +
+ {rows.map((row) => ( + + ))} + {question ? : null} +
+ ); +} + +function ActivityRow({ row }: { row: Activity }) { + switch (row.kind) { + case "user": + return ( +
+ {row.text} +
+ ); + case "note": + return
{row.text}
; + case "tool": + return ( +
+ + {row.text} +
+ ); + case "working": + return ( +
+ + {row.text} +
+ ); + case "result": + return ( +
+ + {row.text} +
+ ); + case "changes": + return ( +
+ + {row.text} +
+ ); + } +} + +function QuestionWidget({ onAnswer }: { onAnswer: (choice: string) => void }) { + const [custom, setCustom] = useState(""); + return ( +
+

{QUESTION}

+
+ {QUESTION_OPTIONS.map((option) => ( + + ))} +
+
+ setCustom(event.target.value)} + placeholder="Or write your own…" + className="min-w-0 flex-1 bg-transparent tr-text-ui text-text-default outline-none placeholder:text-text-subtle" + /> + +
+
+ ); +} + +function WorkbenchSides() { + return ( +
+
+ Files + Specs + Changes +
+
    + + + + + +
+
+ ); +} + +function SideRow({ name }: { name: string }) { + return ( + + + {name} + + ); +} + +function TerminalStrip() { + return ( +
+
+ + Terminal +
+
+				{"~/to-do-app "}
+				(tag-filtering)
+				{" $ git status\nOn branch tag-filtering\nnothing to commit, working tree clean\n$ "}
+				
+			
+
+ ); +} + +function FolderPicker({ onPickFolder }: { onPickFolder: () => void }) { + return ( +
+
+
+ + Your computer + + My Documents + + Projects +
+
+ + + + + + + + + + + + + + + +
+
+
+ ); +} + +function PickerColumn({ + className, + label, + last, + children, +}: { + className?: string; + label?: string; + last?: boolean; + children: ReactNode; +}) { + return ( +
+ {label ? ( + {label} + ) : null} + {children} +
+ ); +} + +function PickerRow({ + icon: Icon, + name, + selected, + chevron, + target, + onSelect, +}: { + icon: LucideIcon; + name: string; + selected?: boolean; + chevron?: boolean; + target?: boolean; + onSelect?: () => void; +}) { + return ( + + ); +} + +function Center({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function CardSpotlight({ + cardRect, + selector, + side, + title, + body, +}: { + cardRect: DOMRect | null; + selector: string; + side: "top" | "right"; + title: string; + body: string; +}) { + const rect = useTargetRect(selector); + if (!rect || !cardRect) return null; + const left = rect.left - cardRect.left; + const top = rect.top - cardRect.top; + const dim = "pointer-events-auto absolute bg-container-workspace-overlay"; + const clamp = (value: number) => Math.max(0, value); + return ( +
+
+
+
+
+
+ + +
+ + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > +

{title}

+

{body}

+ +
+ +
+ ); +} + +function ViewportCoach({ + selector, + side, + title, + body, +}: { + selector: string; + side: "top" | "right"; + title: string; + body: string; +}) { + const rect = useTargetRect(selector); + if (!rect) return null; + const box = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; + return ( + <> +
+ + +
+ + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > +

{title}

+

{body}

+ +
+ + + ); +} + +type Gap = "lg" | "xxl" | "xxxl" | "xxxxl"; + +const GAP_CLASS: Record = { + lg: "mt-lg", + xxl: "mt-xxl", + xxxl: "mt-xxxl", + xxxxl: "mt-xxxxl", +}; + +type OnboardingSection = { + key: string; + gapBefore?: Gap; + render: () => ReactNode; +}; + +function useSequentialReveal(count: number): number { + const [phase, setPhase] = useState(0); + useEffect(() => { + const timers: ReturnType[] = []; + for (let index = 1; index <= count; index++) { + timers.push(setTimeout(() => setPhase(index), 200 + (index - 1) * 700)); + } + return () => timers.forEach(clearTimeout); + }, [count]); + return phase; +} + +function revealClass(shown: boolean): string { + return cn( + "transition-all duration-500 ease-out motion-reduce:transition-none", + shown ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0", + ); +} + +function OnboardingScreen({ + testId, + sections, + cta, +}: { + testId: string; + sections: OnboardingSection[]; + cta: { + label: string; + testId: string; + onClick: () => void; + gapBefore: Gap; + disabled?: boolean; + leading?: ReactNode; + }; +}) { + const steps = sections.length + 1; + const phase = useSequentialReveal(steps); + const ctaRevealed = phase >= steps; + return ( +
+
+ {sections.map((section, index) => { + const revealed = phase >= index + 1; + return ( +
+ {section.render()} +
+ ); + })} +
+
+ {cta.leading} + +
+
+
+
+ ); +} + +function GitStatus({ ready }: { ready: boolean }) { + return ( +
+ Git: + {ready ? ( + + + is Ready + + ) : ( + + )} +
+ ); +} + +function IntroScreen({ onStart }: { onStart: () => void }) { + const [gitReady, setGitReady] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setGitReady(true), 3500); + return () => clearTimeout(timer); + }, []); + const sections: OnboardingSection[] = [ + { + key: "title", + render: () =>

Welcome to {PRODUCT_NAME}

, + }, + { + key: "lede", + gapBefore: "xxl", + render: () => ( +

+ {PRODUCT_NAME} is a worktree IDE built for working with AI agents in parallel. +

+ ), + }, + { + key: "git-copy", + gapBefore: "xxxl", + render: () => ( +

+ {PRODUCT_NAME} works with Git projects + Let's make sure your computer is ready. +

+ ), + }, + ]; + return ( + , + }} + /> + ); +} + +function FinalScreen({ onFinish }: { onFinish: () => void }) { + const sections: OnboardingSection[] = [ + { + key: "title", + render: () =>

That's the workflow.

, + }, + { + key: "lede", + gapBefore: "xxl", + render: () => ( +

+ Now try it with your own project. +

+ ), + }, + ]; + return ( + + ); +} diff --git a/apps/web/src/onboarding/SPEC.md b/apps/web/src/onboarding/SPEC.md new file mode 100644 index 000000000..badef4cfd --- /dev/null +++ b/apps/web/src/onboarding/SPEC.md @@ -0,0 +1,171 @@ +--- +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 + +A **fully self-contained, mocked onboarding simulation**: a large modal card that lets the user *feel* +the complete ThinkRail loop — open a project, create two isolated workspaces, run two agents in parallel — +with **zero** touch of real domain state. Nothing here calls `demo.ensure`/`demo.reset`, creates +Projects/Workspaces/Sessions, invokes pi, opens the OS file picker, or mutates persistence. The fake +project, two fake workspaces, folder picker, prompts, agent responses, and progress all live only in this +component's **local React state**. It is a visual/interaction prototype, easy to iterate or remove. + +Launched from the persistent left-panel `OnboardingLauncher` (or the empty-state Welcome "Try the To Do +App" card); both flip one **view flag** (`store.demoOpen`, a top-level, non-persisted boolean via +`openDemo`/`closeDemo`). While open, `OnboardingSimulation` renders above the real (dimmed) UI. + +## The simulated card + +- A centered modal (~`90vw × 90vh`) over a `bg-overlay` scrim; the card is an opaque `container-workspace-bg` + sandbox rendering a simplified but faithful ThinkRail chrome (header / left `Projects` panel / center), + built from the existing semantic tokens and `components/ui` — **not** wired to any store domain state. +- **Shared intro/outro layout (`OnboardingScreen`).** The first (`step: "intro"`) and final + (`step: "final"`) screens render through **one** private `OnboardingScreen` component — same full-card + overlay (`absolute inset-0`, `bg-container-workspace-bg`, centered, `text-center`), same content column + (`max-w-[720px]`, hero heading unconstrained so it sits on one line and wraps only when the viewport is + genuinely narrow — the future mobile single-view), same sequential fade/translate reveal + (`useSequentialReveal`, `motion-reduce:transition-none`; each section + the CTA carry + `data-revealed`), and a **single primary `Button` CTA** revealed last as the only action in the content + area. The **CTA area is one horizontal action row** (`flex flex-wrap`) so a screen may seat a + **leading control** left of the CTA (the intro's Git status) at the same `h-8` height; it wraps/stacks + on genuinely narrow viewports. Vertical rhythm is layout-owned via per-section `gapBefore` (and a + per-CTA `gapBefore`) mapped to the spacing scale — 16/32/48/64px use `lg`/`xxl`/`xxxl`/`xxxxl` + (`mt-lg`/`mt-xxl`/`mt-xxxl`/`mt-xxxxl`). The large steps are named **outside** Tailwind's + `--container-*` t-shirt scale on purpose: `2xl`/`3xl` there would make Tailwind v4 emit a + spacing-derived `max-w-3xl` that silently shadows the container one `ChatView` relies on + (`mx-auto max-w-3xl`), collapsing the chat column. Typography is mapped to + existing semantic roles only (`tr-brand-hero` hero, `tr-heading-md` subtitle, + `tr-text-ui`/`tr-text-metadata` support) — no component-specific type — and colour uses the + existing Primary/text/container/border/disabled semantics only (no card, no success-green surface). +- **Intro** (`onboarding-intro`, first): reveals in sequence — "Welcome to ThinkRail" (`tr-brand-hero`), + 32px, the product subtitle (`tr-heading-md`, `max-w-[400px]`), 48px, a two-line Git prerequisite + copy (`tr-text-ui`, each sentence its own `block` so the second never wraps up onto the first, yet each + still wraps within a narrow viewport), 16px, then the **action row**: a **single Git status control** + (`onboarding-git`) beside the **"Start demo project"** CTA (`onboarding-start`). There is no + "Before we start" heading. The Git status is one control whose content evolves — `Git:` + the existing + `Loader2` spinner while checking → `Git: is Ready` (`Check` + the semantic **success** role) when + ready (`data-ready`); it is a fully mocked status (a `setTimeout`, no clicks, always ends ready, no + terminal/install detail) that reveals the Git prerequisite before a real project could error. The CTA + is **disabled** (existing disabled control semantics) until the mocked readiness confirms, then + enables. The intro **does not auto-advance**: it stays visible indefinitely until the user clicks the + enabled CTA, which enters the first interactive action. **Nothing here detects/invokes Git** or touches + any real state; it is not a numbered onboarding step, and no coach mark shows during the intro. (The + missing-Git install flow is deliberately out of scope — a future state once Git is bundled vs. + externally installed is decided.) +- **Close demo** (`onboarding-close`, a quiet `Button variant="ghost"` in the card's top-right) is present + throughout — intro included — and is the only explicit pre-completion exit. It only clears the mocked + experience (`closeDemo`); it touches no real state. Coach marks themselves stay non-dismissible. +- **The two predetermined tasks** (used in the dialog prompt *and* later in the composer/chat, one source + of truth): *"Implement a search feature in my To Do app."* and *"Add filtering by tags so I can quickly + show tasks with a specific tag."* +- **Reusing the real Create-workspace dialog.** Step 2 renders the production `panels/NewWorkspaceDialog` + verbatim via a small **injected** `renderCreateDialog` render-prop (the shell composition root supplies + it — same inversion it uses for `SettingsDialog`'s Layout section — so `onboarding` never imports + `panels`, no cycle). The dialog runs in an **inert `preview` mode** (optional props on the shared + component, default off): all its wire reads are skipped, submit is short-circuited to `onPreviewCreate`, + and the Create button's `↵` key-badge is hidden (Create is text-only) — so the exact real dialog UI is + taught while **no** workspace/session/wire work happens. The task is passed as `initialPrompt` and the + seam **types it in** character-by-character (instant under reduced motion), keeping Create inactive + until done and firing `onPreviewReady` so the sim only shows the Create coach once typing finishes. Its + coach uses a **viewport-scoped** spotlight (the dialog is a portaled modal): a pulse ring on Create + a + tooltip to the dialog's right; every other step uses the card-scoped spotlight. +- **Coach marks** reuse the tooltip + arrow treatment: a card-scoped **spotlight** dims everything inside + the card except the current target with the `container-workspace-overlay` scrim (the workspace surface at + the `veil` **50%** alpha step — a sanctioned color token, light enough to keep the interface legible; + see `styles/colors.json`), drawn as four `pointer-events-auto` rects computed relative to the measured + card rect (so only the card interior dims, the raised card stays legible). The Radix + `components/ui/popover` (+ `PopoverArrow`) anchors to the target; Escape / outside-interaction / + auto-focus are all prevented. The tooltip carries **only the title + instruction** — no step number, + progress, or pagination. A coach mark is **non-dismissible** and advances only when its scripted action + completes. +- The current actionable target wears a **temporary pulsing emphasis** — a `ring-2 ring-primary` glow at + the target rect (`motion-safe:animate-pulse`; static under reduced motion), rendered by the spotlight + and moving with it. It is onboarding-only emphasis, never a permanent hover/focus/selected/active state. +- **Coach surface = inverted/high-contrast.** Every coach tooltip (both card- and viewport-scoped) renders + on `bg-primary` with `text-text-on-primary` and a `fill-primary` arrow, so it stands out from the dark + ThinkRail UI. +- **Inverse system surface (design-system extension).** The fake folder picker needed a neutral + light/inverse surface, which the semantic layer previously lacked. Rather than a picker-specific token, + the color model was **extended** with a small, reusable **inverse-surface family** in `styles/colors.json`: + `container-inverse-bg` (the theme *foreground* used as a surface — off-white on the dark themes, dark on + the light ones, i.e. always contrasting the app), `text-on-inverse` (+ `-muted`), `border-inverse`, and + `container-inverse-selected`, all derived from existing palette keys + the shared alpha scale. Any future + "this is the operating system / a system overlay" surface reuses it. Coach marks keep the `primary` + high-contrast treatment (brand emphasis), distinct from this neutral inverse. +- A single **1px progress line** (`onboarding-progress`, `bg-primary`, smooth `transition-[width]`) sits on + the card's bottom edge and advances across the scripted `STEP_ORDER`. No labels/percentages/dots. +- Only the current target is interactive; everything else is present but inert (covered by the dim). + +## Scripted flow (local `step` state machine) + +1. **Open a project** — empty-state Welcome with an "Open project" card (arrow points down to it); clicking + opens a **fake, simplified macOS-Finder-style folder picker** rendered on a **light / inverse system + surface** so it reads as "your computer" opened on top of the dark app (not another ThinkRail panel): + a header labelled **"Your computer"** with a plain **Your computer › My Documents › Projects** + breadcrumb (no technical `/Users/…` path), a `radius-lg` window, and three columns — Locations + (**My Documents** selected) → My Documents contents (**Projects** selected) → `my-app` / `notes` / + `to-do-app`. Only `to-do-app` is interactive (spotlit with the pulsing primary emphasis, coach above + it, arrow down); everything else stays under the dim and does nothing. Selecting `to-do-app` opens the + fake "To Do App" project. The light surface is the sanctioned **`container-inverse-*` / `text-on-inverse*` + / `border-inverse`** semantic family (see below) — no macOS assets or hardcoded grays. The rest of the + onboarding stays dark; only the picker inverts. + The demo card carries a soft **brand glow** — a blurred `bg-primary-soft` layer behind it (not + `feedback-success`; onboarding emphasis, not a success state), extending slightly beyond the card + without altering its background, border, or layout. +2. **Create the first workspace** — coach at the rail `+` opens the **real** `NewWorkspaceDialog` (preview + seam below); the task **types itself** into the real prompt field (focused, blinking caret; instant + under reduced motion), Create stays inactive until typing finishes, then the coach re-points to the + right of the dialog with its arrow on the emphasized **Create**. Clicking Create enters the first + workspace. +3. **First agent starts, and keeps running** — the first workspace shows concise, believable agent + activity (reads files → plan → "Working…") in the real chat visual language and **stays running**; its + rail row keeps a working indicator. The coach points back at the rail `+`: "start a second task — your + first agent keeps working here." +4. **Create the second workspace** — same real dialog + typed task for *"Add filtering by tags…"*. +5. **Second agent + full workbench** — entering the second workspace shows the integrated workbench, all + **mocked** with the real visual language: an active agent chat (center), a right-side + Files/Specs/Changes strip, and a bottom **Terminal** strip — so it reads as ThinkRail, not a bare chat. +6. **Agent asks for feedback** — the second agent pauses with a **question widget** ("Where should tag + filters appear?" + options + a custom field); a coach points at it ("Give the agent feedback"). The + user must choose an option or type their own; on answer the agent acknowledges and resumes. +7. **Parallel payoff** — while the second agent continues, the coach moves to the **left nav** and + highlights the **first** workspace (pulsing primary): "Your agents work in parallel — your first task + kept running. Check its progress." The second workspace visibly stays active; a left-panel note states + switching views never stops a session. +8. **Return to the first workspace** — clicking it shows the first agent **completed** (result + a small + Changes summary), making the "I left, worked elsewhere, came back done" point. + +**Completion** (`step: "final"`, progress 100%) — the **same `OnboardingScreen` layout** as the intro +(no card, no success-green treatment): **"That's the workflow."** (`tr-brand-hero`), 32px, **"Now try it +with your own project."** (`tr-heading-md`), revealed sequentially, then a single **"Start working on your +own project"** CTA (`onboarding-finish`, `closeDemo`) as the only action in the content area — no docs +link. The global **Close demo** control stays. Local state, so reopening replays from the intro. The agent activity, workbench sides, terminal, and question widget are **faithful mocks** (the +real chat/panels/terminal/question components are chat-runtime/store-coupled and can't mount in an +isolated mock); only `NewWorkspaceDialog` is the real component (preview seam). + +## Boundary + +- **Public surface (barrel):** `OnboardingSimulation` (takes a `renderCreateDialog` render-prop), + `OnboardingLauncher`, `useTargetRect` (anchor hook). The store exposes `demoOpen` + `openDemo`/`closeDemo`. +- **Allowed deps:** `store` (`demoOpen`/open/close), `components/ui` (`popover`, `button`), `constants` + (`PRODUCT_NAME`), `lib`, `lucide-react`, React. +- **Forbidden:** `panels`, `shell` internals, `server`/`shared`/`pi`, `transport` (the simulation makes no + network/wire calls). It reuses the real `NewWorkspaceDialog` **without importing `panels`** — the shell + injects it through `renderCreateDialog`. `panels` (`WelcomePanel` card, `ProjectTree` footer launcher) + call `openDemo` — a one-way panels→onboarding edge, no cycle. + +## Dormant (retained, not wired) + +The earlier **real-domain** onboarding coach — `OnboardingDemo`, `OnboardingCoach`, `coach.ts`, +`Spotlight.ts`, `demo.ts` (`startDemo`/`resetDemo`), `persistence.ts`, the `onboarding` store slice + +selectors + `demo.ensure`/`demo.reset` wire — is **kept intact but unwired** (the shell mounts +`OnboardingSimulation`, not `OnboardingDemo`; entry points call `openDemo`). It is preserved deliberately +so this mocked prototype stays easy to iterate on or swap back, and so the server-side bundled-demo +capability (`submodule-server-demo`) remains available for a future real flow. It touches no live UI path. 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 new file mode 100644 index 000000000..f1aa0bf78 --- /dev/null +++ b/apps/web/src/onboarding/coach.ts @@ -0,0 +1,87 @@ +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: 2 | 3 | 4; + title: string; + body: string; + selector: string; + insertPrompt?: string; + sessionId?: string; +} + +export const COACH_STEP_COUNT = 4; + +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: 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: 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}"]`, + }; + } + + const target = demoWorkspaces[step === 1 ? 0 : 1]; + if (!target) return null; + const index = step === 1 ? 3 : 4; + + 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..e71c64ebc --- /dev/null +++ b/apps/web/src/onboarding/index.ts @@ -0,0 +1,7 @@ +export { selectCoach } from "./coach"; +export { resetDemo, startDemo } from "./demo"; +export { OnboardingCoach } from "./OnboardingCoach"; +export { OnboardingDemo } from "./OnboardingDemo"; +export { OnboardingLauncher } from "./OnboardingLauncher"; +export { OnboardingSimulation } from "./OnboardingSimulation"; +export { initOnboardingPersistence, readPersistedOnboarding } from "./persistence"; diff --git a/apps/web/src/onboarding/persistence.ts b/apps/web/src/onboarding/persistence.ts new file mode 100644 index 000000000..32e871dc3 --- /dev/null +++ b/apps/web/src/onboarding/persistence.ts @@ -0,0 +1,42 @@ +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; + 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, + }; + } 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/panels/NewWorkspaceDialog.tsx b/apps/web/src/panels/NewWorkspaceDialog.tsx index 0039dc552..17e155ede 100644 --- a/apps/web/src/panels/NewWorkspaceDialog.tsx +++ b/apps/web/src/panels/NewWorkspaceDialog.tsx @@ -67,6 +67,9 @@ export function NewWorkspaceDialog({ promptNote, onOpenChange, onCreated, + preview = false, + onPreviewCreate, + onPreviewReady, }: { open: boolean; projectId: string; @@ -74,6 +77,9 @@ export function NewWorkspaceDialog({ promptNote?: string; onOpenChange: (open: boolean) => void; onCreated: (workspace: Workspace) => void; + preview?: boolean; + onPreviewCreate?: () => void; + onPreviewReady?: () => void; }) { const projects = useAppStore((s) => s.projects); @@ -86,6 +92,9 @@ export function NewWorkspaceDialog({ const [model, setModel] = useState(null); const [thinkingLevel, setThinkingLevel] = useState("medium"); const [creating, setCreating] = useState(false); + const [previewTyped, setPreviewTyped] = useState(false); + const previewReadyRef = useRef(onPreviewReady); + previewReadyRef.current = onPreviewReady; const [trusting, setTrusting] = useState(false); const [manageSkills, setManageSkills] = useState(false); const promptRef = useRef(null); @@ -115,21 +124,49 @@ export function NewWorkspaceDialog({ useEffect(() => { if (!open) return; setSelectedProjectId(projectId); - setPrompt(initialPrompt ?? ""); + setPrompt(preview ? "" : (initialPrompt ?? "")); + setPreviewTyped(!preview); setTarget("worktree"); setCreating(false); hostDefaultAsked.current = false; - }, [open, projectId, initialPrompt]); + }, [open, projectId, initialPrompt, preview]); useEffect(() => { - if (!open) return; + if (!open || !preview) return; + const full = initialPrompt ?? ""; + const reduce = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!full || reduce) { + setPrompt(full); + setPreviewTyped(true); + previewReadyRef.current?.(); + return; + } + setPrompt(""); + setPreviewTyped(false); + let index = 0; + const id = setInterval(() => { + index += 1; + setPrompt(full.slice(0, index)); + if (index >= full.length) { + clearInterval(id); + setPreviewTyped(true); + previewReadyRef.current?.(); + } + }, 26); + return () => clearInterval(id); + }, [open, preview, initialPrompt]); + + useEffect(() => { + if (!open || preview) return; if (projects.some((p) => p.id === selectedProjectId)) return; onOpenChange(false); toast.info("That project was closed"); - }, [open, projects, selectedProjectId, onOpenChange]); + }, [open, projects, selectedProjectId, onOpenChange, preview]); useEffect(() => { - if (!open) return; + if (!open || preview) return; let cancelled = false; setSkillCommands([]); void slashCommandCatalogOrEmpty(() => @@ -140,10 +177,10 @@ export function NewWorkspaceDialog({ return () => { cancelled = true; }; - }, [open, selectedProjectId]); + }, [open, selectedProjectId, preview]); useEffect(() => { - if (!open) return; + if (!open || preview) return; let cancelled = false; setAliasSkills([]); getTransport() @@ -155,14 +192,14 @@ export function NewWorkspaceDialog({ return () => { cancelled = true; }; - }, [open, selectedProjectId]); + }, [open, selectedProjectId, preview]); const { models, refreshing: modelsRefreshing, refresh: onRefreshModels, fresh: catalogFresh, - } = useModelCatalog(open); + } = useModelCatalog(open && !preview); const applyHostDefault = useCallback(() => { let cancelled = false; @@ -180,12 +217,12 @@ export function NewWorkspaceDialog({ }, []); useEffect(() => { - if (!open) return; + if (!open || preview) return; return applyHostDefault(); - }, [open, applyHostDefault]); + }, [open, preview, applyHostDefault]); useEffect(() => { - if (!open || !model) return; + if (!open || preview || !model) return; const next = reconcileModel(models, model, catalogFresh); if (next === null) return; if (next !== "unavailable") { @@ -195,10 +232,10 @@ export function NewWorkspaceDialog({ if (hostDefaultAsked.current) return; hostDefaultAsked.current = true; return applyHostDefault(); - }, [open, models, model, catalogFresh, applyHostDefault]); + }, [open, models, model, catalogFresh, applyHostDefault, preview]); useEffect(() => { - if (!open || !model) return; + if (!open || preview || !model) return; if (model.thinkingLevels.includes(thinkingLevel)) return; let cancelled = false; getTransport() @@ -214,7 +251,7 @@ export function NewWorkspaceDialog({ return () => { cancelled = true; }; - }, [open, model, thinkingLevel]); + }, [open, model, thinkingLevel, preview]); const prefetchBase = (ref: string) => { if (!ref.startsWith("origin/")) return; @@ -232,13 +269,18 @@ export function NewWorkspaceDialog({ branches, refreshing, refresh: refreshBranches, - } = useBranchList(open ? selectedProjectId : null, (list) => { + } = useBranchList(open && !preview ? selectedProjectId : null, (list) => { setBaseRef(list.defaultBranch); prefetchBase(list.defaultBranch); }); const create = async () => { if (creating) return; + if (preview) { + onPreviewCreate?.(); + onOpenChange(false); + return; + } setCreating(true); let workspace: Workspace; if (target === "default") { @@ -484,14 +526,16 @@ export function NewWorkspaceDialog({
+
); } @@ -386,6 +391,8 @@ function ProjectRow({
); } diff --git a/apps/web/src/store/appStore.ts b/apps/web/src/store/appStore.ts index 2155dc21a..dcbd76a3f 100644 --- a/apps/web/src/store/appStore.ts +++ b/apps/web/src/store/appStore.ts @@ -260,6 +260,24 @@ export interface ChatLocationRequest { navigation?: CenterNavigationStamp | null; } +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, +}; + export interface SessionRuntime { turns: ChatTurn[]; turnIdByMessageIndex?: (string | null)[]; @@ -645,6 +663,8 @@ interface AppState { terminalReplayKb: number; layoutSettings: LayoutSettings; toasts: Toast[]; + onboarding: OnboardingState; + demoOpen: boolean; setStatus: (status: ConnectionStatus) => void; installWelcomeSnapshot: ( protocolVersion: number, @@ -812,6 +832,14 @@ interface AppState { applyReviewChanged: (payload: ReviewChangedPayload) => void; pushToast: (toast: Omit) => string; dismissToast: (id: string) => void; + openDemo: () => void; + closeDemo: () => void; + startDemoTour: () => void; + setDemoStage: (stage: OnboardingStage) => void; + startOnboarding: (demoProjectId: string) => void; + dismissOnboarding: () => void; + resetOnboarding: () => void; + hydrateOnboarding: (onboarding: OnboardingState) => void; } function sortProjects(projects: Project[]): Project[] { @@ -1303,6 +1331,8 @@ export const useAppStore = create((set, get) => ({ terminalReplayKb: DEFAULT_CONFIG.terminalReplayKb, layoutSettings: DEFAULT_CONFIG.layout, toasts: [], + onboarding: NO_ONBOARDING, + demoOpen: false, setStatus: (status) => set((state) => ({ status, @@ -2759,6 +2789,19 @@ export const useAppStore = create((set, get) => ({ set((s) => s.toasts.some((t) => t.id === id) ? { toasts: s.toasts.filter((t) => t.id !== id) } : {}, ), + openDemo: () => set({ demoOpen: true }), + closeDemo: () => set({ demoOpen: false }), + 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", stage: "live", 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..0ad141bb7 --- /dev/null +++ b/apps/web/src/store/onboarding.test.ts @@ -0,0 +1,101 @@ +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", + stage: "live", + 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: { ...onboarding, stage: "welcome" } })).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..50436e837 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,59 @@ 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.stage === "live" && + !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, diff --git a/apps/web/src/styles/COLOR.md b/apps/web/src/styles/COLOR.md index 0c631218c..9434b2574 100644 --- a/apps/web/src/styles/COLOR.md +++ b/apps/web/src/styles/COLOR.md @@ -57,8 +57,8 @@ equals `border-default` is not a second weight, it is a second name). | family | tokens | notes | | --- | --- | --- | -| Text | `text-default` · `text-muted` · `text-subtle` · `text-disabled` · `text-on-primary` | `text-subtle` (from the `hint` palette key) is the secondary-metadata tier (branch lines, spec role labels); `text-disabled` (the default `text` colour @ 60% — a disabled element inherits its enabled semantic colour, dimmed at the token level) is reserved for genuinely disabled UI text (e.g. the Settings "Soon" item) | -| Container | `container-workspace-bg` · `container-sidebar-bg` · `container-terminal-bg` · `container-header-bg` · `container-content-bg` · `container-elevated-bg` | **The opened-document canvas is `workspace`, not `content`**: the Monaco file editor and the markdown/spec preview sit on the same surface as the chat column and the tab strip, so a document reads as part of the workspace. `content` is the **recessed diff canvas** — the Changes diff, rendered diffs, Shiki code blocks, and the Center Workbench backdrop behind them — which is why Monaco defines two themes (`EDITOR_THEME` = workspace, `THEME` = content). `terminal` is a terminal body + xterm canvas (currently sourced from the same palette key as `sidebar`); `elevated` is every raised surface | +| Text | `text-default` · `text-muted` · `text-subtle` · `text-disabled` · `text-on-primary` · `text-on-inverse` · `text-on-inverse-muted` | `text-subtle` (from the `hint` palette key) is the secondary-metadata tier (branch lines, spec role labels); `text-disabled` (the default `text` colour @ 60% — a disabled element inherits its enabled semantic colour, dimmed at the token level) is reserved for genuinely disabled UI text (e.g. the Settings "Soon" item) | +| Container | `container-workspace-bg` · `container-sidebar-bg` · `container-terminal-bg` · `container-header-bg` · `container-content-bg` · `container-elevated-bg` · `container-workspace-overlay` · `container-inverse-bg` · `container-inverse-selected` · `border-inverse` | **The opened-document canvas is `workspace`, not `content`**: the Monaco file editor and the markdown/spec preview sit on the same surface as the chat column and the tab strip, so a document reads as part of the workspace. `content` is the **recessed diff canvas** — the Changes diff, rendered diffs, Shiki code blocks, and the Center Workbench backdrop behind them — which is why Monaco defines two themes (`EDITOR_THEME` = workspace, `THEME` = content). `terminal` is a terminal body + xterm canvas (currently sourced from the same palette key as `sidebar`); `elevated` is every raised surface. `container-workspace-overlay` is the onboarding spotlight scrim (workspace bg @ `veil` 50%). The **`*-inverse*` family** is a neutral system/overlay surface that contrasts the app (the theme *foreground* used as a surface — off-white on the dark themes, dark on the light ones) with `text-on-inverse`(`-muted`), `border-inverse`, and `container-inverse-selected`; used for "this is your operating system" surfaces like the onboarding demo's fake folder picker | | Control | `control-bg` · `control-bg-hovered` · `control-bg-selected` · `control-primary-bg` · `control-primary-bg-hovered` · `control-primary-text` · `control-border-default` · `control-border-active` · `control-disabled-bg` · `control-disabled-text` · `control-disabled-border` · `control-primary-disabled-bg` · `control-primary-disabled-text` | The three `control-primary-*` tokens are the primary button/control, and they are **per-theme derivations like every other role** — `control-primary-bg` (from `accent`) fill, `control-primary-bg-hovered` (from `accentHover`) hover fill, `control-primary-text` (from `onAccent`) label. `accentHover` is the accent's **hover step**, its own manifest key so a theme owns that step: a primary button hovers to `control-primary-bg-hovered` (a colour token), never `hover:opacity-*`. Because the fill comes from `accent`, `bg-control-primary-bg` and `bg-primary` are the same colour by construction — a primary button can never drift from the theme's accent. `control-bg-hovered` is pointer hover only; `control-bg-selected` is the persistent selected/open/active/highlight fill (currently the same palette source). `control-border-default` is the resting form-control border; `control-border-active` (from `borderStrong`) is the **stronger neutral** border of an *active* control — pressed/`active:` buttons, an open selector (`data-[open=true]`), and a focused text input/textarea. It is the border only: the accent focus **ring** stays as the focus indicator (so accent = focus, neutral-strong = active). Never on inactive/default controls, nor on selected nav rows, tabs, or static surfaces. **Disabled is a first-class control state**, not an opacity utility: **a disabled element inherits the same semantic colour it uses when enabled, resolved at the `strong` (60%) alpha step** — derived at the token level so background, text, icon and border keep explicit ownership and nested content is never dimmed. So the disabled roles are the enabled ones @ 60%: `control-disabled-bg` (from `input`, i.e. `control-bg`), `control-disabled-text` (from `text`, i.e. `text-default`), `control-disabled-border` (from `border`, i.e. `control-border-default`), and the primary pair `control-primary-disabled-bg` (from `accent`, i.e. `control-primary-bg`) + `control-primary-disabled-text` (from `onAccent`, i.e. `control-primary-text`). There are **no** dedicated `disabled` / `primaryDisabled` / `onPrimaryDisabled` palette keys — a disabled state never carries a colour of its own. Text/icon-only controls take just `control-disabled-text`; non-control disabled text stays on `text-disabled`. Do **not** use `disabled:opacity-*` on a component (it dims nested content and bypasses token ownership) | | Border | `border-default` · `border-muted` | | | Primary | `primary` + `primary-subtle` · `-soft` · `-muted`, `on-primary-soft` | | diff --git a/apps/web/src/styles/colors.json b/apps/web/src/styles/colors.json index 8f056b77c..30cb9b8ec 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.4.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": 50 }, "roles": { @@ -35,6 +36,40 @@ "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` (50%) alpha step, dimming the demo card while a coach mark spotlights one target — light enough to keep the interface legible underneath" + }, + "container-inverse-bg": { + "from": "text", + "publish": true, + "note": "an inverted/system surface that contrasts the app — the theme's foreground colour used AS a surface (an off-white light panel on the dark themes, a dark panel on the light ones); for temporary 'this is your operating system' surfaces like the onboarding demo's fake folder picker" + }, + "container-inverse-selected": { + "from": "background", + "alpha": "soft", + "publish": true, + "note": "selected-row fill on a `container-inverse` surface: the app background tinted onto the inverse surface" + }, + "text-on-inverse": { + "from": "background", + "publish": true, + "note": "primary text/icons on a `container-inverse` surface (the app background colour, dark on the dark themes)" + }, + "text-on-inverse-muted": { + "from": "background", + "alpha": "strong", + "publish": true, + "note": "secondary text/icons on a `container-inverse` surface: the on-inverse colour at the `strong` (60%) alpha step" + }, + "border-inverse": { + "from": "background", + "alpha": "wash", + "publish": true, + "note": "separators/borders on a `container-inverse` surface" + }, "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..d0af2bbb2 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.4.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,12 @@ --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) 50%, transparent); /* the onboarding spotlight scrim: the workspace surface at the `veil` (50%) alpha step, dimming the demo card while a coach mark spotlights one target — light enough to keep the interface legible underneath */ + --container-inverse-bg: var(--text); /* an inverted/system surface that contrasts the app — the theme's foreground colour used AS a surface (an off-white light panel on the dark themes, a dark panel on the light ones); for temporary 'this is your operating system' surfaces like the onboarding demo's fake folder picker */ + --container-inverse-selected: color-mix(in srgb, var(--background) 20%, transparent); /* selected-row fill on a `container-inverse` surface: the app background tinted onto the inverse surface */ + --text-on-inverse: var(--background); /* primary text/icons on a `container-inverse` surface (the app background colour, dark on the dark themes) */ + --text-on-inverse-muted: color-mix(in srgb, var(--background) 60%, transparent); /* secondary text/icons on a `container-inverse` surface: the on-inverse colour at the `strong` (60%) alpha step */ + --border-inverse: color-mix(in srgb, var(--background) 12%, transparent); /* separators/borders on a `container-inverse` surface */ --container-sidebar-bg: var(--sidebar); --container-terminal-bg: var(--sidebar); --container-header-bg: var(--header); @@ -83,6 +89,12 @@ --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-inverse-bg: var(--container-inverse-bg); + --color-container-inverse-selected: var(--container-inverse-selected); + --color-text-on-inverse: var(--text-on-inverse); + --color-text-on-inverse-muted: var(--text-on-inverse-muted); + --color-border-inverse: var(--border-inverse); --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/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 46878a39a..db574403c 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -35,6 +35,9 @@ --space-md: calc(var(--space-base) * 0.92); --space-lg: calc(var(--space-base) * 1.23); --space-xl: calc(var(--space-base) * 1.85); + --space-xxl: calc(var(--space-base) * 2.46); + --space-xxxl: calc(var(--space-base) * 3.69); + --space-xxxxl: calc(var(--space-base) * 4.92); /* Shared chrome geometry stays independent from typography. */ --panel-header-row-height: 28px; 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/e2e/onboarding-demo.spec.ts b/e2e/onboarding-demo.spec.ts new file mode 100644 index 000000000..05af1586f --- /dev/null +++ b/e2e/onboarding-demo.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from "@playwright/test"; +import { openAppFresh } from "./fixtures/app"; + +const TASK_1 = "Implement a search feature in my To Do app."; +const TASK_2 = "Add filtering by tags so I can quickly show tasks with a specific tag."; + +test("the mocked demo runs intro → parallel-agents payoff, never touching real projects", async ({ + page, +}) => { + await openAppFresh(page); + await expect(page.getByTestId("project-item")).toHaveCount(0); + + await page.getByTestId("onboarding-launch").click(); + await expect(page.getByTestId("onboarding-sim")).toBeVisible(); + await expect(page.getByTestId("onboarding-intro")).toContainText("Welcome to ThinkRail"); + await expect(page.getByTestId("onboarding-intro")).toContainText( + "ThinkRail works with Git projects", + ); + await expect(page.getByTestId("onboarding-progress")).toBeAttached(); + + const coach = page.getByTestId("onboarding-coach"); + const start = page.getByTestId("onboarding-start"); + + await expect(start).toBeDisabled(); + await expect(page.getByTestId("onboarding-git")).toContainText("is Ready"); + await expect(start).toBeEnabled(); + await expect(coach).toHaveCount(0); + await start.click(); + + await expect(coach).toContainText("Open a project"); + await page.getByTestId("sim-open-project").click(); + + await expect(coach).toContainText("Choose your project folder"); + await page.getByTestId("sim-folder").click(); + + await expect(coach).toContainText("Create a workspace"); + await page.getByTestId("sim-add-workspace").click(); + await expect(page.getByTestId("new-workspace-dialog")).toBeVisible(); + await expect(page.getByTestId("ws-prompt")).toHaveValue(TASK_1); + await page.getByTestId("create-workspace").click(); + + await expect(coach).toContainText("Now start a second task"); + await page.getByTestId("sim-add-workspace").click(); + await expect(page.getByTestId("new-workspace-dialog")).toBeVisible(); + await expect(page.getByTestId("ws-prompt")).toHaveValue(TASK_2); + await page.getByTestId("create-workspace").click(); + + const question = page.getByTestId("onboarding-question"); + await expect(question).toBeVisible(); + await expect(coach).toContainText("Give the agent feedback"); + await page.getByTestId("sim-question-option").first().click(); + + await expect(coach).toContainText("Your agents work in parallel"); + await page.getByTestId("sim-ws-0").click(); + + await expect(page.getByTestId("onboarding-final")).toBeVisible(); + await expect(page.getByTestId("onboarding-final")).toContainText("That's the workflow."); + await expect(page.getByTestId("onboarding-final")).toContainText( + "Now try it with your own project.", + ); + await expect(page.getByTestId("onboarding-docs")).toHaveCount(0); + const finish = page.getByTestId("onboarding-finish"); + await expect(finish).toHaveText("Start working on your own project"); + await finish.click(); + + await expect(page.getByTestId("onboarding-sim")).toHaveCount(0); + await expect(page.getByTestId("project-item")).toHaveCount(0); +}); + +test("the intro reveals sequentially, gates the CTA on Git readiness, and needs an explicit click", async ({ + page, +}) => { + await openAppFresh(page); + await page.getByTestId("onboarding-launch").click(); + + const intro = page.getByTestId("onboarding-intro"); + await expect(intro).toBeVisible(); + await expect(intro).not.toContainText("Before we start"); + await expect(intro).toContainText("ThinkRail works with Git projects"); + await expect(intro).toContainText("Let's make sure your computer is ready."); + + const start = page.getByTestId("onboarding-start"); + await expect(start).toHaveAttribute("data-revealed", "false"); + await expect(start).toHaveAttribute("data-revealed", "true"); + + const git = page.getByTestId("onboarding-git"); + await expect(git).toHaveAttribute("data-ready", "false"); + await expect(start).toBeDisabled(); + await expect(git).toHaveAttribute("data-ready", "true"); + await expect(git).toContainText("is Ready"); + await expect(start).toBeEnabled(); + + await expect(page.getByTestId("onboarding-coach")).toHaveCount(0); + await start.click(); + await expect(intro).toHaveCount(0); + await expect(page.getByTestId("onboarding-coach")).toContainText("Open a project"); +}); + +test("the final screen shares the intro layout and reveals sequentially", async ({ page }) => { + await openAppFresh(page); + await page.getByTestId("onboarding-launch").click(); + await page.getByTestId("onboarding-start").click(); + + await page.getByTestId("sim-open-project").click(); + await page.getByTestId("sim-folder").click(); + await page.getByTestId("sim-add-workspace").click(); + await page.getByTestId("create-workspace").click(); + await page.getByTestId("sim-add-workspace").click(); + await page.getByTestId("create-workspace").click(); + await page.getByTestId("sim-question-option").first().click(); + await page.getByTestId("sim-ws-0").click(); + + await expect(page.getByTestId("onboarding-final")).toBeVisible(); + await expect(page.getByTestId("onboarding-progress")).toBeAttached(); + await expect(page.getByTestId("onboarding-close")).toBeVisible(); + + const finish = page.getByTestId("onboarding-finish"); + await expect(finish).toHaveAttribute("data-revealed", "false"); + await expect(finish).toHaveAttribute("data-revealed", "true"); +}); + +test("Close demo leaves the demo at any time without touching real state", async ({ page }) => { + await openAppFresh(page); + await page.getByTestId("onboarding-launch").click(); + + await expect(page.getByTestId("onboarding-intro")).toBeVisible(); + await page.getByTestId("onboarding-close").click(); + + await expect(page.getByTestId("onboarding-sim")).toHaveCount(0); + await expect(page.getByTestId("project-item")).toHaveCount(0); +}); 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/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/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/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/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; +} diff --git a/packages/server/src/demo/SPEC.md b/packages/server/src/demo/SPEC.md new file mode 100644 index 000000000..acc5dfd5f --- /dev/null +++ b/packages/server/src/demo/SPEC.md @@ -0,0 +1,55 @@ +--- +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. + +**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:** + - `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) 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, + 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..b57c3e64c --- /dev/null +++ b/packages/server/src/demo/demo.test.ts @@ -0,0 +1,61 @@ +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(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(""); + 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]); +}); + +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..147411a09 --- /dev/null +++ b/packages/server/src/demo/demo.ts @@ -0,0 +1,31 @@ +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, 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"); +} + +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 setProjectName(initProject(target).id, DEMO_DISPLAY_NAME); +} + +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/host/handlers.ts b/packages/server/src/host/handlers.ts index e7e0c6b99..803007b17 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, @@ -134,6 +144,7 @@ import { ensureWatch, stopWatch } from "../watch"; import { createWorkspace, ensureWorkspaceScratchDir, + forgetProjectWorkspaces, forgetWorkspace, getWorkspace, listExistingWorktrees, @@ -166,6 +177,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 +270,18 @@ 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((ws) => teardownWorkspace(ws))); + forgetProjectWorkspaces(project.id); + 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 +310,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), diff --git a/packages/server/src/projects/SPEC.md b/packages/server/src/projects/SPEC.md index 83b43a22c..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`, - `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 4b647e0b6..9a827188a 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); @@ -115,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, 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);