diff --git a/.gsd/gsd.db-shm b/.gsd/gsd.db-shm new file mode 100644 index 000000000..d8e03167f Binary files /dev/null and b/.gsd/gsd.db-shm differ diff --git a/.gsd/gsd.db-wal b/.gsd/gsd.db-wal new file mode 100644 index 000000000..ac6fcb3ea Binary files /dev/null and b/.gsd/gsd.db-wal differ diff --git a/.gsd/milestones/M001-1ya5a3/slices/S01/S01-RESEARCH.md b/.gsd/milestones/M001-1ya5a3/slices/S01/S01-RESEARCH.md new file mode 100644 index 000000000..e8e2f0013 --- /dev/null +++ b/.gsd/milestones/M001-1ya5a3/slices/S01/S01-RESEARCH.md @@ -0,0 +1,124 @@ +# S01: Electron Shell + Design System Foundation — Research + +**Date:** 2026-03-18 + +## Summary + +S01 delivers the Electron desktop shell, three-column resizable layout, and the full design system that every subsequent slice builds on. The technology stack is well-understood: electron-vite (v5) for the build pipeline, React 19 + TypeScript in the renderer, Tailwind v4 CSS-first configuration for the design tokens, Radix primitives for accessible UI, react-resizable-panels for the three-column layout, and Phosphor Icons. + +The main risk is getting the project scaffolding right — electron-vite imposes a specific directory structure (`src/main/`, `src/preload/`, `src/renderer/`) and the design system must be defined in Tailwind v4's CSS-first `@theme` block rather than a traditional `tailwind.config.js`. The font loading story (Inter + JetBrains Mono) needs to work in Electron's renderer without external network requests — fonts should be bundled as local assets. There are no novel unknowns; this is a scaffolding + design foundation slice. + +## Recommendation + +Use **electron-vite** as the build tool (not raw Vite + vite-plugin-electron). electron-vite provides a single `electron.vite.config.ts` that configures main, preload, and renderer builds with HMR out of the box. The studio app should live at `studio/` in the repo root and be added to the root `package.json` workspaces array. This gives it access to `@gsd/pi-coding-agent` for RPC types (consumed in S02) while keeping it isolated from the CLI build. + +Use **Tailwind v4** with `@tailwindcss/vite` plugin in the renderer Vite config. Define the full color palette, typography scale, and spacing system in a `@theme` block in the main CSS file — no `tailwind.config.js` needed. This is cleaner and gives us CSS custom properties that Radix components and Monaco can reference. + +Use **react-resizable-panels** (v4.7+) for the three-column layout. It handles the Group/Panel/Separator pattern, supports pixel min/max constraints, collapsible panels, and localStorage persistence via the `useDefaultLayout` hook. + +## Implementation Landscape + +### Key Files to Create + +- `studio/package.json` — `@gsd/studio`, private, depends on electron, electron-vite, react, tailwindcss, @tailwindcss/vite, @radix-ui/*, react-resizable-panels, @phosphor-icons/react, zustand +- `studio/electron.vite.config.ts` — electron-vite config with three builds (main/preload/renderer). Renderer config includes `@tailwindcss/vite` and `@vitejs/plugin-react` +- `studio/src/main/index.ts` — Electron main process: `app.whenReady()`, `BrowserWindow` creation with preload, IPC handler stubs for S02. Window config: frameless/custom title bar or native with `titleBarStyle: 'hiddenInset'` for macOS +- `studio/src/preload/index.ts` — `contextBridge.exposeInMainWorld('studio', { ... })` exposing typed IPC channels. Stubs for `gsd:event`, `gsd:send-command`, `gsd:spawn`, `gsd:status` (wired in S02) +- `studio/src/renderer/index.html` — Minimal HTML entry: `
`, loads `src/main.tsx` +- `studio/src/renderer/src/main.tsx` — React root render, imports global CSS +- `studio/src/renderer/src/App.tsx` — Root component: `` with three panels (sidebar, center, right) +- `studio/src/renderer/src/styles/index.css` — `@import "tailwindcss"` + `@theme { }` block defining the full design system +- `studio/src/renderer/src/components/layout/AppLayout.tsx` — Three-column layout using `react-resizable-panels` Group/Panel/Separator +- `studio/src/renderer/src/components/layout/Sidebar.tsx` — Left panel placeholder (file tree goes here in S06) +- `studio/src/renderer/src/components/layout/CenterPanel.tsx` — Center conversation panel placeholder +- `studio/src/renderer/src/components/layout/RightPanel.tsx` — Right editor/preview panel placeholder +- `studio/src/renderer/src/components/layout/PanelHandle.tsx` — Custom-styled drag handle for Separator (amber accent on hover) +- `studio/src/renderer/src/components/layout/TitleBar.tsx` — Custom title bar with app name, traffic light offset, session controls placeholder +- `studio/src/renderer/src/components/ui/Button.tsx` — Core button primitive (Radix Slot pattern for polymorphism, Tailwind variants) +- `studio/src/renderer/src/components/ui/Text.tsx` — Typography component with preset variants (heading, body, label, code) +- `studio/src/renderer/src/components/ui/Icon.tsx` — Thin wrapper around Phosphor icons with default context (size, weight, color) +- `studio/src/renderer/src/lib/theme/tokens.ts` — TypeScript constants mirroring CSS custom properties for programmatic access (used by Monaco theme in S06, Shiki theme in S03) +- `studio/src/renderer/src/assets/fonts/` — Inter and JetBrains Mono font files (woff2), loaded via `@font-face` in the CSS + +### Design System Specification + +The `@theme` block in `index.css` should define: + +**Colors (CSS custom properties):** +- `--color-bg-primary`: `#0a0a0a` (near-black base) +- `--color-bg-secondary`: `#111111` (panels, cards) +- `--color-bg-tertiary`: `#1a1a1a` (elevated surfaces) +- `--color-bg-hover`: `#222222` (hover states) +- `--color-border`: `#262626` (subtle borders) +- `--color-border-active`: `#333333` (focused borders) +- `--color-text-primary`: `#e5e5e5` (primary text) +- `--color-text-secondary`: `#a3a3a3` (secondary text) +- `--color-text-tertiary`: `#737373` (muted text) +- `--color-accent`: `#d4a04e` (warm amber/gold — the signature color) +- `--color-accent-hover`: `#e0b366` (lighter amber on hover) +- `--color-accent-muted`: `rgba(212, 160, 78, 0.15)` (amber wash for backgrounds) + +**Typography:** +- `--font-sans`: `'Inter', system-ui, sans-serif` +- `--font-mono`: `'JetBrains Mono', ui-monospace, monospace` +- Type scale: 11px, 12px, 13px, 14px, 16px, 20px, 24px, 32px + +**Spacing:** 4px base unit grid + +### Build Order + +1. **Scaffold the project** — `studio/package.json`, `electron.vite.config.ts`, directory structure, add `"studio"` to root workspaces. Run `npm install` from root. +2. **Electron main + preload** — BrowserWindow creation, preload with contextBridge stubs. Verify: `npm run dev -w studio` opens a window. +3. **React renderer + Tailwind** — `index.html`, `main.tsx`, `App.tsx`, CSS with `@import "tailwindcss"` and `@theme` block. Verify: window shows styled content. +4. **Font loading** — Bundle Inter and JetBrains Mono woff2 files, `@font-face` declarations. Verify: fonts render in the window. +5. **Three-column layout** — `AppLayout.tsx` with react-resizable-panels, custom separator handles, panel placeholders. Verify: panels resize, drag handles show amber on hover. +6. **Title bar** — Custom title bar component with macOS traffic light offset. Verify: app looks native. +7. **UI primitives** — Button, Text, Icon components. Verify: rendered in placeholder panels with correct styles. +8. **Phosphor Icons** — IconContext provider with default theme values. Verify: icons render at correct size/weight. + +### Verification Approach + +1. `cd studio && npm run dev` launches the Electron window with no errors +2. The window shows three resizable columns with drag handles +3. Dragging handles resizes panels; handles show amber accent on hover +4. Inter font renders in UI text, JetBrains Mono renders in code-styled elements +5. Phosphor icons render at correct size and weight +6. All placeholder panels show styled placeholder content with correct colors +7. HMR works — editing a React component hot-reloads without restarting +8. `npm run build -w studio` produces a working production build in `studio/out/` + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| Electron + Vite build pipeline | `electron-vite` (v5) | Handles main/preload/renderer builds, HMR, and dev server in one config. No need to wire Vite plugins manually. | +| Resizable panel layout | `react-resizable-panels` (v4.7) | Handles drag, keyboard, min/max constraints, localStorage persistence, collapse. Well-tested. | +| Accessible UI primitives | `@radix-ui/*` | Headless, zero-style primitives. Dialog, Tooltip, DropdownMenu, etc. for future slices. S01 only needs the dependency installed. | +| Icon library | `@phosphor-icons/react` (v2.1) | Tree-shakeable, typed, consistent geometric style. `IconContext` for global defaults. | +| CSS framework | `tailwindcss` v4 + `@tailwindcss/vite` | CSS-first config, no JS config file, generates CSS custom properties from `@theme` block. | + +## Constraints + +- **electron-vite directory convention**: Must use `src/main/`, `src/preload/`, `src/renderer/` structure for zero-config. Custom paths require explicit `rollupOptions.input` in each build section. +- **Tailwind v4 has no `tailwind.config.js`**: All theme customization goes in the CSS `@theme` block. This is a new pattern — no JS-side theme object. TypeScript token constants must be manually synced with CSS variables. +- **Fonts must be local**: Electron apps should not depend on Google Fonts CDN. Bundle woff2 files and use `@font-face` with relative paths. +- **Preload script runs in isolated context**: Cannot import renderer modules. Must use `contextBridge.exposeInMainWorld()` to expose IPC channels. TypeScript types can be shared via a `studio/src/shared/` directory. +- **electron-vite v5 requires `@swc/core`**: peer dependency — must be installed. +- **Root workspace**: `studio/` must be added to root `package.json` `"workspaces"` array to access `@gsd/pi-coding-agent` types in S02. + +## Common Pitfalls + +- **Tailwind classes not working in Electron renderer** — The `@tailwindcss/vite` plugin must be added to the `renderer` section of `electron.vite.config.ts`, not the top level. electron-vite has separate Vite configs per process. +- **Context isolation breaks direct IPC** — Cannot use `ipcRenderer` directly in renderer. Must go through `contextBridge` in preload. This is secure but means all IPC channels need explicit exposure. +- **react-resizable-panels API changed in v4** — The library now uses `Group`, `Panel`, `Separator` (not `PanelGroup`, `Panel`, `PanelResizeHandle`). Import names matter. Docs show the v4 API. +- **Font loading flash** — If fonts are loaded asynchronously, there's a brief flash of fallback font. Use `font-display: block` in `@font-face` declarations and preload the font files via `` in `index.html`. +- **macOS title bar** — `titleBarStyle: 'hiddenInset'` gives native traffic lights but overlaps content. Need `CSS: padding-top` or `-webkit-app-region: drag` to create a drag region that doesn't overlap the layout. + +## Skills Discovered + +| Technology | Skill | Status | +|------------|-------|--------| +| Electron | `jezweb/claude-skills@electron-base` (267 installs) | available | +| Electron | `jwynia/agent-skills@electron-best-practices` (112 installs) | available | +| Tailwind v4 | `jezweb/claude-skills@tailwind-v4-shadcn` (2.7K installs) | available (shadcn-oriented, partial relevance) | +| Radix | `yonatangross/orchestkit@radix-primitives` (42 installs) | available | diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 86b8338cb..74f307aa3 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -345,7 +345,7 @@ export async function selectDoctorScope(basePath: string, requestedScope?: strin return state.registry[0]?.id; } -export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise { +export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all"; isolationMode?: "none" | "worktree" | "branch" }): Promise { const issues: DoctorIssue[] = []; const fixesApplied: string[] = []; const fix = options?.fix === true; @@ -386,9 +386,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files) - const isolationMode: "none" | "worktree" | "branch" = - prefs?.preferences?.git?.isolation === "none" ? "none" : - prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree"; + const isolationMode: "none" | "worktree" | "branch" = options?.isolationMode ?? + (prefs?.preferences?.git?.isolation === "none" ? "none" : + prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree"); await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode); // Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore) diff --git a/src/resources/extensions/gsd/tests/doctor-git.test.ts b/src/resources/extensions/gsd/tests/doctor-git.test.ts index 8e3003dd8..e466d0e36 100644 --- a/src/resources/extensions/gsd/tests/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-git.test.ts @@ -72,20 +72,6 @@ function writePreferencesFile(dir: string, isolation: "none" | "worktree" | "bra writeFileSync(join(gsdDir, "preferences.md"), `---\ngit:\n isolation: "${isolation}"\n---\n`); } -/** - * Write preferences to the test runner's cwd .gsd/preferences.md. - * loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH at module - * load time from process.cwd(), so we must write there — not to the temp dir. - */ -const RUNNER_PREFS_PATH = join(process.cwd(), ".gsd", "preferences.md"); -function writeRunnerPreferences(isolation: "none" | "worktree" | "branch"): void { - mkdirSync(join(process.cwd(), ".gsd"), { recursive: true }); - writeFileSync(RUNNER_PREFS_PATH, `---\ngit:\n isolation: "${isolation}"\n---\n`); -} -function removeRunnerPreferences(): void { - try { rmSync(RUNNER_PREFS_PATH); } catch { /* ignore if already gone */ } -} - /** Create a repo with an in-progress milestone. */ function createRepoWithActiveMilestone(): string { const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-"))); @@ -146,12 +132,12 @@ async function main(): Promise { mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); - const detect = await runGSDDoctor(dir); + const detect = await runGSDDoctor(dir, { isolationMode: "worktree" }); const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree"); assertTrue(orphanIssues.length > 0, "detects orphaned worktree"); assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001"); - const fixed = await runGSDDoctor(dir, { fix: true }); + const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" }); assertTrue(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree"); // Verify worktree is gone @@ -174,12 +160,12 @@ async function main(): Promise { // Create a milestone/M001 branch (no worktree) run("git branch milestone/M001", dir); - const detect = await runGSDDoctor(dir); + const detect = await runGSDDoctor(dir, { isolationMode: "worktree" }); const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch"); assertTrue(staleIssues.length > 0, "detects stale milestone branch"); assertEq(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001"); - const fixed = await runGSDDoctor(dir, { fix: true }); + const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" }); assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch"); // Verify branch is gone @@ -265,7 +251,7 @@ async function main(): Promise { mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); - const detect = await runGSDDoctor(dir); + const detect = await runGSDDoctor(dir, { isolationMode: "worktree" }); const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree"); assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned"); } @@ -287,15 +273,9 @@ async function main(): Promise { mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); - // Write preferences to runner's cwd (where the module resolves project prefs) - writeRunnerPreferences("none"); - try { - const result = await runGSDDoctor(dir); - const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree"); - assertEq(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected"); - } finally { - removeRunnerPreferences(); - } + const result = await runGSDDoctor(dir, { isolationMode: "none" }); + const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree"); + assertEq(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected"); } } else { console.log("\n=== none-mode skips orphaned worktree (skipped on Windows) ==="); @@ -311,15 +291,9 @@ async function main(): Promise { // Create a milestone/M001 branch (no worktree) run("git branch milestone/M001", dir); - // Write preferences to runner's cwd - writeRunnerPreferences("none"); - try { - const result = await runGSDDoctor(dir); - const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch"); - assertEq(staleIssues.length, 0, "none-mode: stale branch NOT detected"); - } finally { - removeRunnerPreferences(); - } + const result = await runGSDDoctor(dir, { isolationMode: "none" }); + const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch"); + assertEq(staleIssues.length, 0, "none-mode: stale branch NOT detected"); } } else { console.log("\n=== none-mode skips stale branch (skipped on Windows) ==="); @@ -335,15 +309,9 @@ async function main(): Promise { const headHash = run("git rev-parse HEAD", dir); writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n"); - // Write preferences to runner's cwd - writeRunnerPreferences("none"); - try { - const result = await runGSDDoctor(dir); - const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state"); - assertTrue(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected"); - } finally { - removeRunnerPreferences(); - } + const result = await runGSDDoctor(dir, { isolationMode: "none" }); + const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state"); + assertTrue(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected"); } // ─── Test 10: none-mode still detects tracked runtime files ──────── @@ -359,15 +327,9 @@ async function main(): Promise { run("git add -f .gsd/activity/test.log", dir); run("git commit -m \"track runtime file\"", dir); - // Write preferences to runner's cwd - writeRunnerPreferences("none"); - try { - const result = await runGSDDoctor(dir); - const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files"); - assertTrue(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected"); - } finally { - removeRunnerPreferences(); - } + const result = await runGSDDoctor(dir, { isolationMode: "none" }); + const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files"); + assertTrue(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected"); } } finally { diff --git a/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts index 8a8dd02d7..e10b9020c 100644 --- a/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +++ b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts @@ -83,11 +83,11 @@ test("stopAutoRemote cleans up stale lock (dead PID) and returns found:false", ( test("stopAutoRemote sends SIGTERM to a live process and returns found:true", async () => { const base = makeTmpBase(); - // Spawn a child process that sleeps, acting as a fake auto-mode session + // Spawn a child process that prints "ready" then sleeps, acting as a fake auto-mode session const child = spawn( process.execPath, - ["-e", "process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"], - { stdio: "ignore", detached: false }, + ["-e", "process.on('SIGTERM', () => process.exit(0)); process.stdout.write('ready'); setTimeout(() => process.exit(1), 30000);"], + { stdio: ["ignore", "pipe", "ignore"], detached: false }, ); if (!child.pid) { @@ -95,8 +95,11 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as } try { - // Wait for child to be ready - await new Promise((resolve) => setTimeout(resolve, 200)); + // Wait for child to signal readiness via stdout + await new Promise((resolve) => { + child.stdout!.once("data", () => resolve()); + setTimeout(resolve, 2000); // fallback timeout + }); // Write lock with child's PID const lockData = { diff --git a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts index b621a43a4..865813e07 100644 --- a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts @@ -209,13 +209,13 @@ _None_ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", repo); // Detect - const detect = await runGSDDoctor(repo); + const detect = await runGSDDoctor(repo, { isolationMode: "worktree" }); const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree"); assertTrue(orphanIssues.length > 0, "doctor detects orphaned worktree"); assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001"); // Fix - const fixed = await runGSDDoctor(repo, { fix: true }); + const fixed = await runGSDDoctor(repo, { fix: true, isolationMode: "worktree" }); assertTrue( fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "doctor fix removes orphaned worktree",