diff --git a/.agents/skills/pierre-guard/SKILL.md b/.agents/skills/pierre-guard/SKILL.md deleted file mode 100644 index 5c1160ea2..000000000 --- a/.agents/skills/pierre-guard/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: pierre-guard -description: Guard against breaking the @pierre/diffs integration in Plannotator's code review UI. Use this skill whenever modifying DiffViewer.tsx, upgrading the @pierre/diffs package, changing unsafeCSS injection, adding new props to FileDiff, or touching shadow DOM selectors or CSS variables that cross into Pierre's shadow boundary. Also trigger when someone asks "will this break the diff viewer", "is this safe to change", or when reviewing PRs that touch the review-editor package. ---- - -# Pierre Integration Guard - -Plannotator's code review UI wraps `@pierre/diffs` — an open-source diff renderer that uses Shadow DOM. The integration is concentrated in a single file but relies on undocumented internals (shadow DOM selectors, CSS variable names, grid layout assumptions). This skill helps verify changes don't break that contract. - -## Source of Truth - -- **Upstream repo**: https://github.com/pierrecomputer/pierre/tree/main/packages/diffs -- **Local types**: `node_modules/@pierre/diffs/dist/` (`.d.ts` files) -- **Integration point**: `packages/review-editor/components/DiffViewer.tsx` -- **Current version**: check `packages/review-editor/package.json` for the pinned version - -Always verify against the upstream repo or local `.d.ts` files — don't rely on memory of the API shape. - -## What We Import - -```typescript -import { FileDiff } from '@pierre/diffs/react'; -import { getSingularPatch, processFile } from '@pierre/diffs'; -``` - -These are the only three imports. `DiffViewer.tsx` is the only file that touches Pierre. - -## API Surface to Guard - -### 1. Component Props (`FileDiff`) - -Read the current prop types from `node_modules/@pierre/diffs/dist/react/index.d.ts` or the upstream source. The props we use: - -| Prop | Type | Notes | -|------|------|-------| -| `fileDiff` | `FileDiffMetadata` | From `getSingularPatch()` or `processFile()` | -| `options` | `FileDiffOptions` | See options table below | -| `lineAnnotations` | `DiffLineAnnotation[]` | `{ side, lineNumber, metadata }` | -| `selectedLines` | `SelectedLineRange \| null` | `{ start, end, side }` | -| `renderAnnotation` | `(ann) => ReactNode` | Custom inline annotation renderer | -| `renderHoverUtility` | `(getHoveredLine) => ReactNode` | The `+` button on hover (deprecated upstream — watch for removal) | - -### 2. Options Object - -| Option | Value We Pass | Risk | -|--------|--------------|------| -| `themeType` | `'dark' \| 'light'` | Low — standard enum | -| `unsafeCSS` | CSS string | **High** — targets internal selectors | -| `diffStyle` | `'split' \| 'unified'` | Low — standard enum | -| `diffIndicators` | `'bars'` | Low | -| `hunkSeparators` | `'line-info'` | Low | -| `enableLineSelection` | `true` | Low | -| `enableHoverUtility` | `true` | Medium — deprecated prop | -| `onLineSelectionEnd` | callback | Medium — signature could change | - -### 3. Shadow DOM Selectors (via `unsafeCSS`) - -These are the selectors we inject CSS rules against. They target `data-*` attributes inside Pierre's shadow DOM. If Pierre renames or removes any of these, our styling breaks silently. - -**Currently used:** -- `:host` — shadow root -- `[data-diff]` — root diff container -- `[data-file]` — file wrapper -- `[data-diffs-header]` — header bar -- `[data-error-wrapper]` — error display -- `[data-virtualizer-buffer]` — virtual scroll buffer -- `[data-file-info]` — file metadata row -- `[data-column-number]` — line number gutter -- `[data-diffs-header] [data-title]` — title (we hide it) -- `[data-diff-type='split']` — split layout mode -- `[data-overflow='scroll']` / `[data-overflow='wrap']` — overflow mode - -### 4. CSS Variables We Override - -We override these `--diffs-*` variables to theme Pierre: - -- `--diffs-bg`, `--diffs-fg` — base colors -- `--diffs-dark-bg`, `--diffs-light-bg` — theme-specific backgrounds -- `--diffs-dark`, `--diffs-light` — theme-specific foregrounds - -### 5. CSS Variables We Inject (Custom) - -We set these on a wrapper div outside the shadow DOM, relying on CSS custom property inheritance: - -- `--split-left`, `--split-right` — control the split pane grid ratio - -The `unsafeCSS` grid override references these: `grid-template-columns: var(--split-left, 1fr) var(--split-right, 1fr)`. The `1fr` fallback ensures the layout is safe if the variables aren't set. - -### 6. Grid Layout Assumption - -Pierre's split view uses CSS Grid with `grid-template-columns: 1fr 1fr`. We override this for the resizable split pane. If Pierre changes its layout engine (e.g., to flexbox or a different grid structure), the override will stop working. - -**How to verify:** In the upstream source, search for `grid-template-columns` in the diff component styles. - -## Verification Checklist - -When reviewing changes that touch the Pierre integration, check: - -### Props & Types -- [ ] Read the current `.d.ts` files to confirm prop names and types haven't changed -- [ ] Check if `renderHoverUtility` is still supported (it's deprecated — may be removed) -- [ ] Verify `DiffLineAnnotation` still uses `side: 'deletions' | 'additions'` (not `'old' | 'new'`) -- [ ] Confirm `SelectedLineRange` shape: `{ start, end, side? }` - -### Shadow DOM Selectors -- [ ] Grep the upstream source for each `data-*` attribute we target in `unsafeCSS` -- [ ] If upgrading the package version, diff the old and new CSS/HTML output for renamed attributes -- [ ] Test both `split` and `unified` views — selectors are layout-dependent - -### CSS Variables -- [ ] Grep upstream for `--diffs-bg`, `--diffs-fg`, and other variables we override -- [ ] Verify the variable names haven't been renamed or removed -- [ ] Check that `!important` is still needed (Pierre may change specificity) - -### Theme Compliance -- [ ] New UI elements must use theme tokens (`bg-border`, `bg-primary`, etc.), not hardcoded colors like `bg-blue-500` -- [ ] The existing `ResizeHandle` component in `packages/ui/components/ResizeHandle.tsx` sets the visual convention — match it - -### Build & Runtime -- [ ] Run `bun run dev:review` and verify the diff renders in both split and unified modes -- [ ] Check the browser console for Pierre warnings (e.g., `parseLineType: Invalid firstChar`) -- [ ] Test with add-only and delete-only files (Pierre doesn't render split grid for these) -- [ ] If changing UI code, remember build order: `bun run --cwd apps/review build && bun run build:hook` - -## When Upgrading @pierre/diffs - -1. Check the upstream changelog / commit history at https://github.com/pierrecomputer/pierre -2. Diff the `.d.ts` files between old and new versions: - ```bash - # Before upgrading, snapshot current types - cp -r node_modules/@pierre/diffs/dist /tmp/pierre-old - # After upgrading - diff -r /tmp/pierre-old node_modules/@pierre/diffs/dist - ``` -3. Search for renamed/removed data attributes in the new version -4. Run through the full verification checklist above -5. Test the resizable split pane — it depends on grid layout internals diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03eef3e49..ba982e327 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Install dependencies run: bun install - - name: Generate Pi extension shared copies + - name: Generate Pi extension shared helpers run: bash apps/pi-extension/vendor.sh - name: Type check @@ -68,7 +68,6 @@ jobs: - name: Build UI run: | - bun run build:review bun run build:hook - name: Compile binaries (cross-compile all targets) @@ -168,24 +167,41 @@ jobs: PLANNOTATOR_PORT="$port" "$@" & local pid=$! local ok=0 + # Daemon control routes (/daemon/*) require the daemon auth token, + # which is written to the daemon state file. Read it and send it as + # a bearer token on the smoke's requests, or every call 401s. + local state_file="${PLANNOTATOR_DATA_DIR:-$HOME/.plannotator}/daemon.json" for _ in $(seq 1 60); do - if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then - ok=1 - break + local token="" + if [ -f "$state_file" ]; then + token="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('authToken',''))" "$state_file" 2>/dev/null || true)" + fi + if [ -n "$token" ]; then + local sessions + sessions="$(curl -sf -H "Authorization: Bearer ${token}" "http://127.0.0.1:${port}/daemon/sessions" 2>/dev/null || true)" + if [ -n "$sessions" ]; then + local session_url + session_url="$(python3 -c 'import json,sys; data=json.load(sys.stdin); sessions=data.get("sessions") or []; print(sessions[0].get("url","") if sessions else "")' <<< "$sessions")" + if [ -n "$session_url" ] && curl -sf -H "Authorization: Bearer ${token}" "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then + ok=1 + break + fi + fi fi sleep 0.5 done kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true + PLANNOTATOR_PORT="$port" "$BINARY" daemon stop >/dev/null 2>&1 || true if [ "$ok" = "0" ]; then - echo "FAIL: ${label} did not respond on :${port}${endpoint}" + echo "FAIL: ${label} did not expose a daemon-scoped ${endpoint}" exit 1 fi - echo "OK: ${label} responded on :${port}${endpoint}" + echo "OK: ${label} exposed daemon-scoped ${endpoint}" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. @@ -229,36 +245,67 @@ jobs: -RedirectStandardError $stderr $ok = $false + # Daemon control routes (/daemon/*) require the daemon auth token + # from the state file; send it as a bearer token or requests 401. + $dataDir = if ($env:PLANNOTATOR_DATA_DIR) { $env:PLANNOTATOR_DATA_DIR } else { Join-Path $env:USERPROFILE ".plannotator" } + $stateFile = Join-Path $dataDir "daemon.json" + $diagToken = $false + $diagSessions = "(none)" + $diagSessionUrl = "(none)" + $diagApi = "(not reached)" try { for ($i = 0; $i -lt 60; $i++) { try { - Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null - $ok = $true - break + $token = "" + if (Test-Path $stateFile) { + $token = (Get-Content $stateFile -Raw | ConvertFrom-Json).authToken + } + if ($token) { + $diagToken = $true + $headers = @{ Authorization = "Bearer $token" } + $sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -Headers $headers -UseBasicParsing -TimeoutSec 2 + $diagSessions = "HTTP $($sessionsResponse.StatusCode)" + $sessionsBody = $sessionsResponse.Content | ConvertFrom-Json + if ($sessionsBody.sessions.Count -gt 0) { + # The daemon binds IPv4 127.0.0.1, but the session url uses + # "localhost", which Invoke-WebRequest resolves to IPv6 ::1 + # first on Windows and then fails to connect. Force IPv4. + $sessionUrl = ($sessionsBody.sessions[0].url) -replace '://localhost:', '://127.0.0.1:' + $diagSessionUrl = $sessionUrl + $apiResponse = Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -Headers $headers -UseBasicParsing -TimeoutSec 2 + $diagApi = "HTTP $($apiResponse.StatusCode)" + $ok = $true + break + } + } } catch { + $diagApi = "exception: $($_.Exception.Message)" if ($process.HasExited) { break } - Start-Sleep -Milliseconds 500 } + Start-Sleep -Milliseconds 500 } } finally { if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force Wait-Process -Id $process.Id -ErrorAction SilentlyContinue } + & $binary daemon stop *> $null Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue } if (-not $ok) { + Write-Host "diag: stateFile=$stateFile exists=$(Test-Path $stateFile) tokenSeen=$diagToken" + Write-Host "diag: sessions=$diagSessions sessionUrl=$diagSessionUrl api=$diagApi" Write-Host "stdout:" Get-Content $stdout -ErrorAction SilentlyContinue Write-Host "stderr:" Get-Content $stderr -ErrorAction SilentlyContinue - throw "FAIL: $Label did not respond on :$Port$Endpoint" + throw "FAIL: $Label did not expose a daemon-scoped $Endpoint" } - Write-Host "OK: $Label responded on :$Port$Endpoint" + Write-Host "OK: $Label exposed daemon-scoped $Endpoint" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. @@ -267,37 +314,6 @@ jobs: # 3. annotate: exercises annotate server startup with a real file. Test-PlannotatorServer "plannotator annotate" "19501" "/api/plan" @("annotate", "README.md") - pi-extension-ai-runtime-windows: - needs: test - # Exercises the Pi extension's Node/jiti server mirror on Windows with an - # npm-style `pi` shim pair. The binary smoke above covers the compiled Bun - # CLI, but the published Pi extension uses this separate Node path. - name: Pi extension AI runtime (Windows) - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.3.11 - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 24 - - - name: Install dependencies - run: bun install - - - name: Generate Pi extension shared copies - shell: bash - run: bash apps/pi-extension/vendor.sh - - - name: Build Pi AI runtime smoke - run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs" - - - name: Run Pi AI runtime smoke - run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs" - install-script-smoke: needs: build runs-on: ${{ matrix.os }} @@ -454,7 +470,6 @@ jobs: needs: - build - smoke-binaries - - pi-extension-ai-runtime-windows - install-script-smoke if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest @@ -521,7 +536,6 @@ jobs: needs: - build - smoke-binaries - - pi-extension-ai-runtime-windows - install-script-smoke runs-on: ubuntu-latest permissions: @@ -544,7 +558,6 @@ jobs: - name: Build packages run: | - bun run build:review bun run build:hook bun run build:opencode bun run build:pi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7fa897e9c..d102d7872 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: bun install - - name: Generate Pi extension shared copies + - name: Generate Pi extension shared helpers run: bash apps/pi-extension/vendor.sh - name: Type check @@ -31,37 +31,6 @@ jobs: - name: Run tests run: bun test - pi-extension-ai-runtime-windows: - # Exercises the Pi extension's Node/jiti server mirror on Windows with an - # npm-style `pi` shim pair. This catches regressions where `where pi` - # resolves the extensionless shim before pi.cmd and the Ask AI provider - # crashes before the plan review UI opens. - name: Pi extension AI runtime (Windows) - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: latest - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 24 - - - name: Install dependencies - run: bun install - - - name: Generate Pi extension shared copies - shell: bash - run: bash apps/pi-extension/vendor.sh - - - name: Build Pi AI runtime smoke - run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs" - - - name: Run Pi AI runtime smoke - run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs" - install-cmd-windows: # End-to-end integration test for scripts/install.cmd on real cmd.exe. # The unit tests in scripts/install.test.ts are file-content string checks diff --git a/.gitignore b/.gitignore index 50bb2eb93..f6d9dd35f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,4 @@ plannotator-local # Local research/reference docs (not for repo) /reference/ /Plannotator Waitlist Signup/ -# Local goal setup packages generated by the setup-goal skill. -/goals/ *.bun-build diff --git a/AGENTS.md b/AGENTS.md index c2c7f1a30..c27594fc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,13 +11,13 @@ plannotator/ │ │ ├── .claude-plugin/plugin.json │ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) │ │ ├── hooks/hooks.json # PermissionRequest hook config -│ │ ├── server/index.ts # Entry point (plan + review + annotate + archive subcommands) -│ │ └── dist/ # Built single-file apps (index.html, review.html) -│ ├── opencode-plugin/ # OpenCode plugin +│ │ └── server/index.ts # CLI entry point (daemon client, session orchestration) +│ ├── frontend/ # Production frontend SPA (daemon shell) +│ │ ├── src/ # React app with TanStack Router +│ │ └── vite.config.ts # Single-file HTML build +│ ├── opencode-plugin/ # OpenCode plugin (binary client wrapper) │ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) -│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers -│ │ ├── plannotator.html # Built plan review app -│ │ └── review-editor.html # Built code review app +│ │ └── index.ts # Plugin entry — spawns plannotator binary │ ├── amp-plugin/ # Amp plugin │ │ ├── plannotator.ts # Native Amp command-palette integration │ │ └── README.md # Install and local development notes @@ -31,10 +31,6 @@ plannotator/ │ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors) │ │ ├── stores/ # Storage backends (fs, kv, s3) │ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts) -│ ├── review/ # Standalone review server (for development) -│ │ ├── index.html -│ │ ├── index.tsx -│ │ └── vite.config.ts │ ├── vscode-extension/ # VS Code extension — opens plans in editor tabs │ │ ├── bin/ # Router scripts (open-in-vscode, xdg-open) │ │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts @@ -48,15 +44,15 @@ plannotator/ │ └── plannotator-visual-explainer/ # Visual HTML generator (plans, diagrams, PR explainers) with Plannotator theming ├── packages/ │ ├── server/ # Shared server implementation -│ │ ├── index.ts # startPlannotatorServer(), handleServerReady() -│ │ ├── review.ts # startReviewServer(), handleReviewServerReady() -│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() +│ │ ├── index.ts # createPlannotatorSession(), handleServerReady() +│ │ ├── review.ts # createReviewSession(), handleReviewServerReady() +│ │ ├── annotate.ts # createAnnotateSession(), handleAnnotateServerReady() +│ │ ├── daemon/ # Long-running daemon runtime, state, client, and session store │ │ ├── storage.ts # Re-exports from @plannotator/shared/storage │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() │ │ ├── browser.ts # openBrowser() │ │ ├── draft.ts # Re-exports from @plannotator/shared/draft -│ │ ├── integrations.ts # Obsidian, Bear integrations │ │ ├── ide.ts # VS Code diff integration (openEditorDiff) │ │ ├── editor-annotations.ts # VS Code editor annotation endpoints │ │ └── project.ts # Project name detection for tags @@ -65,44 +61,60 @@ plannotator/ │ │ ├── components/ # Viewer, Toolbar, Settings, etc. │ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views -│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser +│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser │ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below) │ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator │ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks │ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders │ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer) -│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog) +│ │ │ └── code-review/ # Scopes for code-review surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog) │ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator) │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts -│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts +│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) │ ├── shared/ # Shared types, utilities, and cross-runtime logic -│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) +│ │ ├── storage.ts # Plan saving, version history (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) -│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) -│ ├── editor/ # Plan review app +│ │ ├── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ ├── plugin-protocol.ts # JSON protocol for binary-owned plugin commands +│ │ ├── plugin-client.ts # Shared OpenCode/Pi subprocess client for plannotator plugin commands +│ │ └── plugin-binary.ts # Binary discovery, compatibility checks, and installer bridge +│ ├── plannotator-plan-review/ # Plan review app (embedded in frontend) │ │ ├── App.tsx # Main plan review app -│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries -│ └── review-editor/ # Code review UI +│ │ └── shortcuts.ts # planReviewSurface + annotateSurface +│ └── plannotator-code-review/ # Code review UI (embedded in frontend) │ ├── App.tsx # Main review app -│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry +│ ├── shortcuts.ts # codeReviewSurface │ ├── components/ # DiffViewer, FileTree, ReviewSidebar │ ├── dock/ # Dockview center panel infrastructure -│ ├── demoData.ts # Demo diff for standalone mode -│ └── index.css # Review-specific styles +│ └── store/ # Zustand review store (annotations, files, diff options) ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` -## Server Runtimes +## Architecture -There are two separate server implementations with the same API surface: +The `plannotator` binary is the only server. One server, one frontend, many entry points. -- **Bun server** (`packages/server/`) — used by both Claude Code (`apps/hook/`) and OpenCode (`apps/opencode-plugin/`). These plugins import directly from `@plannotator/server`. -- **Pi server** (`apps/pi-extension/server/`) — a standalone Node.js server for the Pi extension. It mirrors the Bun server's API but uses `node:http` primitives instead of Bun's `Request`/`Response` APIs. +``` +Host app (Claude Code / OpenCode / Pi / Codex / Copilot / Gemini CLI) + → thin wrapper (hook, extension, plugin) + → plannotator binary (CLI) + → daemon (one per machine) + → frontend (browser) +``` + +- The binary either starts a daemon or connects to one already running. The daemon serves the frontend. +- Claude Code calls the binary directly via hooks. OpenCode, Pi, Codex, Copilot, and Gemini CLI call it via thin extension/plugin wrappers that spawn the binary as a subprocess. +- Extensions and plugins have no server logic of their own. They translate "my host app wants to do X" into "shell out to `plannotator`." +- The frontend (`apps/frontend/`) is the only UI. + +## Server Implementation -When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/` and is imported by both. +Server logic lives in `packages/server/`. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/`. The plugin protocol for extensions is in `packages/shared/plugin-protocol.ts` and `plugin-client.ts`. + +Daemon-backed commands run through one long-running `plannotator` process per user/machine environment. `plannotator daemon start|status|stop` manage that lifecycle, while normal plan/review/annotate commands auto-start a compatible daemon and create session-scoped browser URLs at `/s/`. Browser API calls must use `/s//api/...`; root `/api/...` routes are not a daemon session boundary. ## Installation @@ -125,6 +137,8 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | +| `PLANNOTATOR_BIN` | Explicit `plannotator` binary path for OpenCode/Pi plugin clients. Overrides PATH and standard install locations. | +| `PLANNOTATOR_DISABLE_AUTO_INSTALL` | Set to `1` / `true` to make OpenCode/Pi fail clearly instead of running the official installer when no compatible binary is found. | | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | @@ -137,6 +151,7 @@ claude --plugin-dir ./apps/hook **Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly: - `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`). +- `legacyTabMode` (`true` / `false`, default `false`) — when enabled, the daemon opens a new browser tab for every session regardless of whether a frontend is already connected. Sessions use the full-screen `CompletionOverlay` with auto-close instead of the inline `CompletionBanner`. Preserves the pre-frontend tab-per-session behavior for users who prefer it. **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. @@ -219,41 +234,60 @@ User annotates content, provides feedback Send Annotations → feedback sent to agent session ``` -## Archive Flow +## Server API -``` -User runs plannotator archive (CLI) or /plannotator-archive (Pi) - ↓ -Server starts in mode:"archive", reads ~/.plannotator/plans/ - ↓ -Browser opens read-only archive viewer (sharing disabled) - ↓ -User browses saved plan decisions with approved/denied badges - ↓ -Done → POST /api/done closes the browser -``` +### Daemon Runtime (`packages/server/daemon/`) -During normal plan review, an Archive sidebar tab provides the same browsing via linked doc overlay without leaving the current session. +The daemon is the single long-running Bun server used by normal plan/review/annotate commands. It owns a session store and exposes browser sessions at `/s/`. Session browser APIs are scoped under `/s//api/...`; root `/api/...` is not a valid daemon session API boundary. -## Server API +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/daemon/capabilities` | GET | Return daemon protocol/capability metadata | +| `/daemon/status` | GET | Return daemon process, endpoint, and session counts | +| `/daemon/sessions` | GET | List active sessions (`?clean=1` also reaps expired sessions before listing) | +| `/daemon/sessions` | POST | Create a plan/review/annotate session from a plugin-protocol request | +| `/daemon/sessions/:id` | GET | Fetch a session summary | +| `/daemon/sessions/:id/result` | GET | Wait for a session decision/result | +| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources | +| `/daemon/sessions/:id` | DELETE | Delete a session record | +| `/daemon/shutdown` | POST | Ask the daemon to stop | +| `/daemon/config` | GET | Read global config (`~/.plannotator/config.json`) | +| `/daemon/config` | POST | Write global config keys (allowlisted: `displayName`, `pfmReminder`, `legacyTabMode`, `diffOptions`, `conventionalComments`, `conventionalLabels`) | +| `/daemon/git/user` | GET | Return git user name from `git config user.name` | +| `/daemon/hooks/status` | GET | Return PFM reminder and improvement hook status | +| `/daemon/projects` | DELETE | Remove a project by `?cwd=` (optional `?clean=1` to cancel active sessions) | +| `/daemon/projects/prs` | GET | List open PRs for a project (`?cwd=`) | +| `/daemon/projects/prs/detailed` | GET | List PRs with review metadata for dashboard (`?cwd=`) | +| `/daemon/fs/list` | GET | List directory contents (`?path=`) | +| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, session revision events, and correlated session actions | +| `/s/:id` | GET | Serve the browser HTML for a session | +| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler | + +Runtime live updates for daemon lifecycle events, external annotations, agent jobs, and session revisions are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`. + +### Session Persistence and Resubmission + +When a user denies a plan (or sends feedback on a review/annotation), the session enters `awaiting-resubmission` status instead of completing. The session's HTTP handler stays alive. When the agent replans and submits again via `POST /daemon/sessions`, the daemon matches the new submission to the existing session by a match key (`plan:project:slug` for plans, `review:${prUrl}` for PR/MR reviews or `review:project:branch` for local reviews, `annotate:project:filePath` for single-file annotations). The session reactivates in place — the frontend receives a `session-revision` event via WebSocket with the updated content. For PR/MR reviews, reactivation also refreshes the PR metadata (head SHA, and the `prSwitchCache` entry the submit path reads) so platform actions (approve/comment) target the current head commit, not the SHA captured when the review first opened. + +**Sessions never die.** No session type calls `store.complete()` from its decision handler. All sessions survive feedback, approve, and exit — the HTTP handler stays alive and the tab keeps working. `registerPersistentDecision` always calls `store.suspend()`. `registerReviewDecision` always calls `store.idle()`. Non-terminal sessions have no expiry timer. + +**Session statuses (plan/annotate):** `active` → `awaiting-resubmission` (on any decision) → `active` (on resubmit) → `awaiting-resubmission` ... repeating. + +**Session statuses (code review):** `active` → `idle` (on any decision) → `active` (on agent resubmit) → `idle` ... repeating. + +**Event families:** `daemon`, `external-annotations`, `agent-jobs`, `session-revision`. ### Plan Server (`packages/server/index.ts`) | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/plan` | GET | Returns `{ plan, origin, previousPlan, versionInfo }` (plan mode) or `{ plan, origin, mode: "archive", archivePlans }` (archive mode) | +| `/api/plan` | GET | Returns `{ plan, origin, previousPlan, versionInfo }` | | `/api/plan/version` | GET | Fetch specific version (`?v=N`) | | `/api/plan/versions` | GET | List all versions of current plan | -| `/api/archive/plans` | GET | List archived plan decisions (`?customPath=`) | -| `/api/archive/plan` | GET | Fetch archived plan content (`?filename=&customPath=`) | -| `/api/done` | POST | Close archive browser (archive mode only) | -| `/api/approve` | POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) | +| `/api/approve` | POST | Approve plan (body: planSave, agentSwitch, feedback) | | `/api/deny` | POST | Deny plan (body: feedback, planSave) | | `/api/image` | GET | Serve image by path query param | | `/api/upload` | POST | Upload image, returns `{ path, originalName }` | -| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults | -| `/api/reference/obsidian/files` | GET | List vault markdown files as nested tree (`?vaultPath=`) | -| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=&path=`) | | `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) | | `/api/doc` | GET | Serve linked .md/.mdx file (`?path=`) | | `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) returns `{ results: { [path]: { status: "found"\|"ambiguous"\|"missing"\|"unavailable", … } } }` | @@ -266,8 +300,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | | `/api/ai/sessions` | GET | List active sessions | -| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations | -| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) | +| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) | | `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | | `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) | | `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all | @@ -292,14 +325,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | | `/api/ai/sessions` | GET | List active sessions | -| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations | -| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) | +| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) | | `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | | `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) | | `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all | | `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, tour) | -| `/api/agents/jobs/stream` | GET | SSE stream for real-time agent job status updates | -| `/api/agents/jobs` | GET | Snapshot of agent jobs (polling fallback, `?since=N` for version gating) | +| `/api/agents/jobs` | GET | Snapshot of agent jobs (`?since=N` for version gating) | | `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) | | `/api/agents/jobs` | DELETE | Kill all running agent jobs | | `/api/agents/jobs/:id` | DELETE | Kill a specific agent job | @@ -315,7 +346,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml? }` | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan, versionInfo }` | +| `/api/plan/version` | GET | Fetch specific version (`?v=N`) — single-file annotate only | +| `/api/plan/versions` | GET | List all versions — single-file annotate only | | `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | | `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) | | `/api/exit` | POST | Close session without feedback | @@ -330,8 +363,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/ai/abort` | POST | Abort the current query | | `/api/ai/permission` | POST | Respond to a permission request | | `/api/ai/sessions` | GET | List active sessions | -| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations | -| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) | +| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) | | `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | | `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) | | `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all | @@ -371,7 +403,7 @@ When a user denies a plan and Claude resubmits, the UI shows what changed betwee **Annotation hook** (`packages/ui/hooks/useAnnotationHighlighter.ts`): Annotation infrastructure used by `Viewer.tsx`. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. The diff view uses its own block-level hover system instead. -**Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with three tabs — Table of Contents, Version Browser, and Archive. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). In archive mode, the sidebar opens to the Archive tab automatically. +**Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with tabs — Table of Contents, Version Browser, and File Browser. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). ## Data Types @@ -445,13 +477,13 @@ Text highlighting uses `web-highlighter` library. Code blocks use manual ` ## Keyboard Shortcuts -**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/editor/shortcuts.ts` and `packages/review-editor/shortcuts.ts` (per-app surfaces). +**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/plannotator-plan-review/shortcuts.ts` and `packages/plannotator-code-review/shortcuts.ts` (per-app surfaces). The shortcut system has three layers: 1. **Engine** (`packages/ui/shortcuts/{core,runtime}.ts`) — parser for declarative bindings (`Mod+Enter`, `Alt Alt` double-tap, `Alt hold`), dispatcher, platform-aware formatter (mac glyphs vs. `Ctrl`), validator, and the `useShortcutScope` / `useDoubleTapShortcuts` React hooks. Truly shared — both apps use it as-is. 2. **Scopes** — `defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } })`. One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives in `packages/ui/shortcuts/{plan-review,code-review}/` — **the subfolder names which app's UI the scope serves**. Components/Apps wire handlers to a scope via `useShortcutScope({ scope, handlers: { actionId: () => ... } })`. -3. **Surfaces** (`packages/editor/shortcuts.ts`, `packages/review-editor/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page. +3. **Surfaces** (`packages/plannotator-plan-review/shortcuts.ts`, `packages/plannotator-code-review/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page. **Convention for adding new shortcuts:** define the action in the relevant scope file under the right subfolder (`plan-review/` or `code-review/`), declare the binding(s) and description, then wire a handler at the call site with `useShortcutScope`. The marketing docs page picks it up automatically at next build. Unit tests in `packages/ui/shortcuts.test.ts` enforce normalized binding tokens (`Mod`, `Shift`, `Alt`, `A-Z`, `1-0`, named keys, `F1`–`F12`) and unique scope ids. @@ -521,8 +553,7 @@ Code blocks use bundled `highlight.js`. Language is extracted from fence (```rus bun install # Run any app -bun run dev:hook # Hook server (plan review) -bun run dev:review # Review editor (code review) +bun run dev:frontend # Frontend + daemon dev server bun run dev:portal # Portal editor bun run dev:marketing # Marketing site bun run dev:vscode # VS Code extension (watch mode) @@ -531,9 +562,8 @@ bun run dev:vscode # VS Code extension (watch mode) ## Build ```bash -bun run build:hook # Single-file HTML for hook server -bun run build:review # Code review editor -bun run build:opencode # OpenCode plugin (copies HTML from hook + review) +bun run build:hook # Builds the frontend, then the binary embeds it +bun run build:opencode # OpenCode plugin bun run build:portal # Static build for share.plannotator.ai bun run build:marketing # Static build for plannotator.ai bun run build:vscode # VS Code extension bundle @@ -543,23 +573,13 @@ bun run build # Build hook + opencode (main targets) **Important: Tailwind `@source` paths.** When creating new directories that contain `.tsx` files with Tailwind classes, add a matching `@source` entry to the app's `index.css`. Tailwind only generates CSS for classes it finds in scanned files — missing paths means classes appear in the DOM but have no effect. -**Important: Build order matters.** The hook build (`build:hook`) copies pre-built HTML from `apps/review/dist/`. If you change UI code in `packages/ui/`, `packages/editor/`, or `packages/review-editor/`, you **must** rebuild the review app first, then the hook: - -```bash -bun run --cwd apps/review build && bun run build:hook # For review UI changes -bun run build:hook # For plan UI changes only -bun run build:hook && bun run build:opencode # For OpenCode plugin -``` - -Running only `build:hook` after review-editor changes will copy stale HTML files. When testing locally with a compiled binary, the full sequence is: +The hook build (`build:hook`) builds the frontend app (`apps/frontend/`) into a single-file HTML, which the daemon embeds and serves. When testing locally with a compiled binary: ```bash -bun run --cwd apps/review build && bun run build:hook && \ +bun run build:hook && \ bun build apps/hook/server/index.ts --compile --outfile ~/.local/bin/plannotator ``` -Running only `build:opencode` will copy stale HTML files. - ## Marketing Site `apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main. diff --git a/README.md b/README.md index 9ab6eeebf..9559399b2 100644 --- a/README.md +++ b/README.md @@ -328,8 +328,8 @@ bun install bun link ``` -After linking, commands like `plannotator review` use `apps/hook/server/index.ts` from your local repo. Rebuild the bundled HTML when changing UI code: +After linking, commands like `plannotator review` use `apps/hook/server/index.ts` from your local repo. Rebuild the frontend when changing UI code: ```bash -bun run --cwd apps/review build && bun run build:hook +bun run build:hook ``` diff --git a/apps/amp-plugin/README.md b/apps/amp-plugin/README.md index 3e3f977a3..fd680285b 100644 --- a/apps/amp-plugin/README.md +++ b/apps/amp-plugin/README.md @@ -12,43 +12,68 @@ not intercept Amp's planning flow. ## Install -Install the `plannotator` CLI first: - -```bash -curl -fsSL https://plannotator.ai/install.sh | bash -``` - -Then install the Amp plugin: +Install the bundled (self-contained) plugin file: ```bash mkdir -p ~/.config/amp/plugins -curl -fsSL https://raw.githubusercontent.com/backnotprop/plannotator/main/apps/amp-plugin/plannotator.ts \ +curl -fsSL https://raw.githubusercontent.com/backnotprop/plannotator/main/apps/amp-plugin/dist/plannotator.ts \ -o ~/.config/amp/plugins/plannotator.ts ``` Restart Amp or run `plugins: reload` from the command palette. -For project-local installation, copy the plugin to: +For project-local installation, copy the bundled file to: ```text .amp/plugins/plannotator.ts ``` +The plugin auto-installs the `plannotator` CLI on first use if it isn't already +on your system. To install it ahead of time: + +```bash +curl -fsSL https://plannotator.ai/install.sh | bash +``` + +## How it works + +The plugin is a thin wrapper. Each command shells out to the `plannotator` +binary over the JSON plugin protocol (`plannotator plugin review|annotate`, +`--origin amp`) via the shared client in `@plannotator/shared/plugin-client` — +the same path OpenCode and Pi use. The binary starts (or reuses) the local +daemon, opens the browser itself, and reports the session URL back over the +protocol. When the user sends feedback, the plugin appends it to the active Amp +thread. The plugin never spawns a server, waits on a ready file, or scrapes +stderr for URLs. + ## Local Development -From a Plannotator checkout: +From a Plannotator checkout, symlink the source file into your project and run +`plugins: reload` in Amp: ```bash mkdir -p .amp/plugins ln -sf ../../apps/amp-plugin/plannotator.ts .amp/plugins/plannotator.ts -export PLANNOTATOR_AMP_USE_SOURCE=1 export PLANNOTATOR_CWD="$PWD" ``` -Run `plugins: reload` in Amp. When the plugin is loaded from this repository, it -runs the checkout's source entrypoint instead of a global `plannotator` binary. -You can also point directly at a source entry: +When loaded from inside the repo, the plugin auto-discovers the checkout's source +entrypoint (`findPlannotatorSourceRoot`) and runs it through Bun, so you don't +need a compiled binary on PATH. To point at a specific binary instead, set +`PLANNOTATOR_BIN`: ```bash -export PLANNOTATOR_AMP_SOURCE_ENTRY=/path/to/plannotator/apps/hook/server/index.ts +export PLANNOTATOR_BIN=/path/to/plannotator ``` + +### Distribution build + +The published plugin is a single self-contained file. Bundle it (inlining the +shared client) with: + +```bash +bun run build:amp # writes apps/amp-plugin/dist/plannotator.ts +``` + +The raw `apps/amp-plugin/plannotator.ts` imports `@plannotator/shared` and only +resolves inside a repo checkout; ship `dist/plannotator.ts` for standalone use. diff --git a/apps/amp-plugin/ampcode-plugin.d.ts b/apps/amp-plugin/ampcode-plugin.d.ts new file mode 100644 index 000000000..23e64e3fa --- /dev/null +++ b/apps/amp-plugin/ampcode-plugin.d.ts @@ -0,0 +1,80 @@ +/** + * Minimal ambient type surface for `@ampcode/plugin`. + * + * The Amp runtime injects this module at load time; it is not installed as a + * package (the plugin is distributed as a single file copied into + * `~/.config/amp/plugins/`). These declarations cover only the surface this + * plugin actually uses so the source typechecks standalone. + */ +declare module "@ampcode/plugin" { + export interface PluginLogger { + log(...args: unknown[]): void; + } + + export interface CommandSpec { + title: string; + category?: string; + description?: string; + } + + export interface PluginAPI { + logger: PluginLogger; + registerCommand( + id: string, + spec: CommandSpec, + handler: (ctx: PluginCommandContext) => void | Promise, + ): void; + } + + export interface UiInputOptions { + title?: string; + helpText?: string; + submitButtonText?: string; + } + + export interface PluginUI { + input(options: UiInputOptions): Promise; + notify(message: string): Promise; + } + + export interface ThreadAppendEntry { + type: "user-message"; + content: string; + } + + export interface ThreadMessagesQuery { + from?: "start" | "end"; + limit?: number; + roles?: Array<"assistant" | "user">; + } + + export interface Thread { + messages(query?: ThreadMessagesQuery): Promise; + append(entries: ThreadAppendEntry[]): Promise; + } + + export interface ShellResult { + exitCode: number; + stdout: string; + stderr: string; + } + + export interface PluginCommandContext { + ui: PluginUI; + thread?: Thread; + $(strings: TemplateStringsArray, ...values: unknown[]): Promise; + } + + export interface ThreadMessageContentBlock { + type: string; + text?: string; + thinking?: string; + [key: string]: unknown; + } + + export interface ThreadMessage { + role: "assistant" | "user"; + id: string; + content: ThreadMessageContentBlock[]; + } +} diff --git a/apps/amp-plugin/binary-client.ts b/apps/amp-plugin/binary-client.ts new file mode 100644 index 000000000..a9621e074 --- /dev/null +++ b/apps/amp-plugin/binary-client.ts @@ -0,0 +1 @@ +export * from "../../packages/shared/plugin-client"; diff --git a/apps/amp-plugin/package.json b/apps/amp-plugin/package.json new file mode 100644 index 000000000..10a9b48f8 --- /dev/null +++ b/apps/amp-plugin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@plannotator/amp-plugin", + "version": "0.19.24", + "type": "module", + "description": "Plannotator plugin for Amp - interactive code review and annotation via the Amp command palette", + "author": "backnotprop", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/backnotprop/plannotator.git", + "directory": "apps/amp-plugin" + }, + "homepage": "https://github.com/backnotprop/plannotator", + "bugs": { + "url": "https://github.com/backnotprop/plannotator/issues" + }, + "keywords": ["amp", "amp-plugin", "plannotator", "code-review", "annotate", "ai-agent", "coding-agent"], + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "bun build plannotator.ts --outfile dist/plannotator.ts --target bun --format esm --external @ampcode/plugin", + "prepublishOnly": "bun run build" + }, + "dependencies": {}, + "peerDependencies": { + "bun": ">=1.0.0" + }, + "devDependencies": { + "@plannotator/shared": "workspace:*" + } +} diff --git a/apps/amp-plugin/plannotator.test.ts b/apps/amp-plugin/plannotator.test.ts index 8b12a5aa0..f56b9c276 100644 --- a/apps/amp-plugin/plannotator.test.ts +++ b/apps/amp-plugin/plannotator.test.ts @@ -4,20 +4,32 @@ import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + buildBaseRequest, buildEnv, - buildPlannotatorEnv, extractTextFromThreadMessage, findFirstPositionalArg, formatAnnotationFeedback, getPlannotatorDataDir, - getPlannotatorCommandCandidates, + handleAnnotateResult, + handleReviewResult, isNoActionFeedback, - parseAnnotateDecision, parseReviewTargetInput, resolveAmpWorkspaceRoot, resolveCwd, + runAnnotate, + runAnnotateLast, + runReview, splitCommandArgs, + type BinaryClientDeps, } from "./plannotator"; +import { type EnsurePlannotatorBinaryResult } from "../../packages/shared/plugin-client"; +import { + createPluginErrorResponse, + createPluginSuccessResponse, + type PluginAnnotateResult, + type PluginResponse, + type PluginReviewResult, +} from "../../packages/shared/plugin-protocol"; describe("Amp Plannotator plugin helpers", () => { test("extracts visible assistant text blocks", () => { @@ -35,12 +47,6 @@ describe("Amp Plannotator plugin helpers", () => { expect(text).toBe("First paragraph.\n\nSecond paragraph."); }); - test("parses structured annotate decisions", () => { - expect(parseAnnotateDecision('{"decision":"approved"}')).toEqual({ decision: "approved" }); - expect(parseAnnotateDecision("")).toEqual({ decision: "dismissed" }); - expect(parseAnnotateDecision("plain feedback")).toBeNull(); - }); - test("wraps actionable annotation feedback for Amp thread append", () => { expect( formatAnnotationFeedback( @@ -204,12 +210,31 @@ describe("Amp Plannotator plugin helpers", () => { } }); - test("ready-file mode preserves Plannotator browser opening", () => { - expect(buildPlannotatorEnv("/repo", "/tmp/ready.jsonl")).toEqual({ - PLANNOTATOR_ORIGIN: "amp", - PLANNOTATOR_CWD: "/repo", - PLANNOTATOR_READY_FILE: "/tmp/ready.jsonl", - }); + test("populates the shared base request with amp origin and sharing fields", () => { + const originalShare = process.env.PLANNOTATOR_SHARE; + const originalShareUrl = process.env.PLANNOTATOR_SHARE_URL; + const originalPasteUrl = process.env.PLANNOTATOR_PASTE_URL; + + try { + delete process.env.PLANNOTATOR_SHARE; + process.env.PLANNOTATOR_SHARE_URL = "https://share.example.com"; + process.env.PLANNOTATOR_PASTE_URL = "https://paste.example.com"; + + expect(buildBaseRequest("/repo")).toEqual({ + origin: "amp", + cwd: "/repo", + sharingEnabled: true, + shareBaseUrl: "https://share.example.com", + pasteApiUrl: "https://paste.example.com", + }); + + process.env.PLANNOTATOR_SHARE = "disabled"; + expect(buildBaseRequest("/repo").sharingEnabled).toBe(false); + } finally { + restoreEnv("PLANNOTATOR_SHARE", originalShare); + restoreEnv("PLANNOTATOR_SHARE_URL", originalShareUrl); + restoreEnv("PLANNOTATOR_PASTE_URL", originalPasteUrl); + } }); test("does not let Amp's Bun mode leak into the Plannotator binary", () => { @@ -236,53 +261,204 @@ describe("Amp Plannotator plugin helpers", () => { restoreEnv("PLANNOTATOR_DATA_DIR", originalDataDir); } }); +}); - test("prefers installer binary paths before PATH lookup", () => { - expect( - getPlannotatorCommandCandidates({ - home: "/Users/alice", - pluginDir: "/Users/alice/.config/amp/plugins", - platform: "darwin", - env: {}, - }), - ).toEqual([ - ["/Users/alice/.local/bin/plannotator"], - ["plannotator"], - ]); +describe("Amp Plannotator binary-client wiring", () => { + test("review sends origin amp, joined args, and appends prompt feedback", async () => { + const captured: { binaryPath?: string; request?: Record } = {}; + const appended: string[] = []; + const notes: string[] = []; + + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => okBinary(), + runPluginReview: async (binaryPath, request) => { + captured.binaryPath = binaryPath; + captured.request = request as unknown as Record; + return success({ approved: true, prompt: "LGTM, ship it." }); + }, + }; + + await runReview(fakeAmp(), fakeCtx({ appended, notes }), "--git https://example.com/pr/1", deps); + + expect(captured.binaryPath).toBe("/bin/plannotator"); + expect(captured.request).toMatchObject({ + origin: "amp", + args: "--git https://example.com/pr/1", + sharingEnabled: expect.any(Boolean), + }); + expect(appended).toEqual(["LGTM, ship it."]); + expect(notes).toEqual([]); + }); - expect( - getPlannotatorCommandCandidates({ - home: String.raw`C:\Users\alice`, - pluginDir: String.raw`C:\Users\alice\.config\amp\plugins`, - platform: "win32", - env: { - LOCALAPPDATA: String.raw`C:\Users\alice\AppData\Local`, - USERPROFILE: String.raw`C:\Users\alice`, - }, - }), - ).toEqual([ - [String.raw`C:\Users\alice\AppData\Local/plannotator/plannotator.exe`], - [String.raw`C:\Users\alice/.local/bin/plannotator.exe`], - ["plannotator"], + test("review falls back to feedback when no prompt is present", async () => { + const appended: string[] = []; + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => okBinary(), + runPluginReview: async () => + success({ approved: false, feedback: "Please fix this bug." }), + }; + + await runReview(fakeAmp(), fakeCtx({ appended }), "", deps); + + expect(appended).toEqual(["Please fix this bug."]); + }); + + test("review exit is a no-op (no append)", async () => { + const appended: string[] = []; + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => okBinary(), + runPluginReview: async () => success({ approved: false, exit: true }), + }; + + await runReview(fakeAmp(), fakeCtx({ appended }), "", deps); + + expect(appended).toEqual([]); + }); + + test("annotate sends origin amp and the raw target string", async () => { + const captured: { request?: Record } = {}; + const appended: string[] = []; + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => okBinary(), + runPluginAnnotate: async (_binaryPath, request) => { + captured.request = request as unknown as Record; + return success({ feedback: "", prompt: "Address the annotations." }); + }, + }; + + await runAnnotate(fakeAmp(), fakeCtx({ appended }), "docs/plan.md --gate", deps); + + expect(captured.request).toMatchObject({ origin: "amp", args: "docs/plan.md --gate" }); + expect(appended).toEqual(["Address the annotations."]); + }); + + test("annotate-last sends origin amp, mode, markdown, and last-message file path", async () => { + const captured: { request?: Record } = {}; + const appended: string[] = []; + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => okBinary(), + runPluginAnnotate: async (_binaryPath, request) => { + captured.request = request as unknown as Record; + return success({ feedback: "", prompt: "Revise the message." }); + }, + }; + + await runAnnotateLast(fakeAmp(), fakeCtx({ appended }), "assistant message body", deps); + + expect(captured.request).toMatchObject({ + origin: "amp", + mode: "annotate-last", + markdown: "assistant message body", + filePath: "last-message", + }); + expect(appended).toEqual(["Revise the message."]); + }); + + test("annotate approved is a no-op (no append)", async () => { + const appended: string[] = []; + const notes: string[] = []; + + await handleAnnotateResult( + fakeCtx({ appended, notes }), + success({ feedback: "", approved: true }), + { kind: "message" }, + ); + + expect(appended).toEqual([]); + expect(notes).toEqual(["Annotation session closed."]); + }); + + test("annotate falls back to template-wrapped feedback when no prompt", async () => { + const appended: string[] = []; + + await handleAnnotateResult( + fakeCtx({ appended }), + success({ feedback: "Comment: tighten this section." }), + { kind: "file", filePath: "docs/plan.md" }, + ); + + expect(appended).toEqual([ + "# Markdown Annotations\n\nFile: docs/plan.md\n\nComment: tighten this section.\n\nPlease address the annotation feedback above.", ]); }); - test("allows explicit PLANNOTATOR_BIN override", () => { - expect( - getPlannotatorCommandCandidates({ - home: "/Users/alice", - pluginDir: "/Users/alice/.config/amp/plugins", - platform: "darwin", - env: { PLANNOTATOR_BIN: "/opt/plannotator/bin/plannotator" }, + test("review error surfaces the plugin error message", async () => { + const notes: string[] = []; + + await handleReviewResult( + fakeCtx({ notes }), + error("plugin-command-failed", "daemon unavailable"), + ); + + expect(notes[0]).toContain("daemon unavailable"); + }); + + test("missing binary notifies an install hint", async () => { + const notes: string[] = []; + const deps: BinaryClientDeps = { + ensurePlannotatorBinary: () => ({ + ok: false, + code: "missing-binary", + message: "The Plannotator binary was not found and automatic installation is disabled.", + checked: [], }), - ).toEqual([ - ["/opt/plannotator/bin/plannotator"], - ["/Users/alice/.local/bin/plannotator"], - ["plannotator"], - ]); + }; + + await runReview(fakeAmp(), fakeCtx({ notes }), "", deps); + + expect(notes[0]).toContain("Plannotator review failed."); + expect(notes[0]).toContain("https://plannotator.ai/docs/getting-started/installation/"); }); }); +function okBinary(): EnsurePlannotatorBinaryResult { + return { + ok: true, + path: "/bin/plannotator", + source: "path", + installed: false, + capabilities: { + protocol: "plannotator-plugin", + protocolVersion: 2, + minClientVersion: 1, + features: ["capabilities", "plan-review", "code-review", "annotate", "annotate-last"], + daemonReady: true, + }, + }; +} + +function success(result: T): PluginResponse { + return createPluginSuccessResponse(result) as PluginResponse; +} + +function error(code: string, message: string): PluginResponse { + return createPluginErrorResponse(code, message) as PluginResponse; +} + +function fakeAmp(): Parameters[0] { + return { + logger: { log: () => {} }, + } as unknown as Parameters[0]; +} + +function fakeCtx( + sinks: { appended?: string[]; notes?: string[] }, +): Parameters[0] { + return { + $: async () => ({ exitCode: 0, stdout: `${process.cwd()}\n`, stderr: "" }), + ui: { + notify: async (message: string) => { + sinks.notes?.push(message); + }, + }, + thread: { + append: async (entries: Array<{ type: string; content: string }>) => { + for (const entry of entries) sinks.appended?.push(entry.content); + }, + }, + } as unknown as Parameters[0]; +} + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key]; diff --git a/apps/amp-plugin/plannotator.ts b/apps/amp-plugin/plannotator.ts index 91903f3c8..287ffd355 100644 --- a/apps/amp-plugin/plannotator.ts +++ b/apps/amp-plugin/plannotator.ts @@ -1,14 +1,26 @@ import type { PluginAPI, PluginCommandContext, ThreadMessage } from "@ampcode/plugin"; -import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { + ensurePlannotatorBinary, + findPlannotatorSourceRoot, + runPluginAnnotate, + runPluginReview, + type CommandRunOptions, + type EnsurePlannotatorBinaryResult, +} from "./binary-client"; +import type { + PluginAnnotateRequest, + PluginAnnotateResult, + PluginFeature, + PluginResponse, + PluginReviewRequest, + PluginReviewResult, +} from "../../packages/shared/plugin-protocol"; const CATEGORY = "Plannotator"; const INSTALL_URL = "https://plannotator.ai/docs/getting-started/installation/"; -const READY_TIMEOUT_MS = 30_000; -const MIN_READY_FILE_VERSION = "0.19.24"; -const MIN_STDIN_LAST_VERSION = "0.19.24"; const RUNTIME = "amp"; const DEFAULT_ANNOTATE_FILE_FEEDBACK_PROMPT = @@ -17,36 +29,14 @@ const DEFAULT_ANNOTATE_MESSAGE_FEEDBACK_PROMPT = "# Message Annotations\n\n{{feedback}}\n\nPlease address the annotation feedback above."; type CommandContext = PluginCommandContext; -type ReadyResult = "ready" | "exited" | "timeout"; -interface RunResult { - status: number; - stdout: string; - stderr: string; - error?: string; +/** Dependency seam so command handlers can be exercised with fake clients in tests. */ +export interface BinaryClientDeps { + ensurePlannotatorBinary?: typeof ensurePlannotatorBinary; + runPluginReview?: typeof runPluginReview; + runPluginAnnotate?: typeof runPluginAnnotate; } -interface AnnotateDecision { - decision: "approved" | "dismissed" | "annotated"; - feedback?: string; -} - -interface ExitState { - done: boolean; -} - -interface PlannotatorRuntime { - command: string[]; - source: "cli" | "source"; - version: string | null; - features: { - readyFile: boolean; - stdinLast: boolean; - }; -} - -let runtimePromise: Promise | null = null; - export default function plannotatorAmpPlugin(amp: PluginAPI) { amp.logger.log("[plannotator] Amp plugin initialized"); @@ -58,8 +48,7 @@ export default function plannotatorAmpPlugin(amp: PluginAPI) { description: "Open Plannotator code review for the current workspace changes.", }, async (ctx) => { - const result = await runPlannotator(amp, ctx, ["review"]); - await handleReviewResult(ctx, result); + await runReview(amp, ctx, ""); }, ); @@ -80,8 +69,7 @@ export default function plannotatorAmpPlugin(amp: PluginAPI) { const reviewArgs = parseReviewTargetInput(target); if (!reviewArgs) return; - const result = await runPlannotator(amp, ctx, ["review", ...reviewArgs]); - await handleReviewResult(ctx, result); + await runReview(amp, ctx, reviewArgs.join(" ")); }, ); @@ -100,12 +88,7 @@ export default function plannotatorAmpPlugin(amp: PluginAPI) { }); if (!target?.trim()) return; - const args = splitCommandArgs(target); - if (args.length === 0) return; - const filePath = findFirstPositionalArg(args) ?? args[0]; - - const result = await runPlannotator(amp, ctx, ["annotate", ...args, "--json"]); - await handleAnnotateResult(ctx, result, { kind: "file", filePath }); + await runAnnotate(amp, ctx, target.trim()); }, ); @@ -128,71 +111,252 @@ export default function plannotatorAmpPlugin(amp: PluginAPI) { return; } - const runtime = await getPlannotatorRuntime(); - let tempFile: string | null = null; - let result: RunResult; - - try { - if (runtime.features.stdinLast) { - result = await runPlannotator( - amp, - ctx, - ["annotate-last", "--stdin", "--json"], - { stdin: message, runtime }, - ); - } else { - tempFile = join(tmpdir(), `plannotator-amp-last-${process.pid}-${Date.now()}-${randomUUID()}.md`); - writeFileSync(tempFile, message, "utf8"); - result = await runPlannotator(amp, ctx, ["annotate", tempFile, "--json"], { runtime }); - } - } finally { - if (tempFile) { - try { - unlinkSync(tempFile); - } catch { - // Best-effort cleanup for the fallback message file. - } - } - } - - await handleAnnotateResult(ctx, result, { kind: "message" }); + await runAnnotateLast(amp, ctx, message); }, ); } +// ── Command runners ───────────────────────────────────────────────────────── + +export async function runReview( + amp: PluginAPI, + ctx: CommandContext, + args: string, + deps: BinaryClientDeps = {}, +): Promise { + const cwd = await resolveCwd(ctx); + const binary = ensureBinary(["code-review"], deps); + if (!binary.ok) { + await ctx.ui.notify(failureMessage("review", binary)); + return; + } + + const request: PluginReviewRequest = { + ...buildBaseRequest(cwd), + args, + }; + const response = await (deps.runPluginReview ?? runPluginReview)( + binary.path, + request, + undefined, + runOptions(amp, ctx, cwd), + ); + + await handleReviewResult(ctx, response); +} + +export async function runAnnotate( + amp: PluginAPI, + ctx: CommandContext, + args: string, + deps: BinaryClientDeps = {}, +): Promise { + const cwd = await resolveCwd(ctx); + const binary = ensureBinary(["annotate"], deps); + if (!binary.ok) { + await ctx.ui.notify(failureMessage("annotate", binary)); + return; + } + + const filePath = findFirstPositionalArg(splitCommandArgs(args)) ?? args; + const request: PluginAnnotateRequest = { + ...buildBaseRequest(cwd), + args, + }; + const response = await (deps.runPluginAnnotate ?? runPluginAnnotate)( + binary.path, + request, + undefined, + runOptions(amp, ctx, cwd), + ); + + await handleAnnotateResult(ctx, response, { kind: "file", filePath }); +} + +export async function runAnnotateLast( + amp: PluginAPI, + ctx: CommandContext, + message: string, + deps: BinaryClientDeps = {}, +): Promise { + const cwd = await resolveCwd(ctx); + const binary = ensureBinary(["annotate-last"], deps); + if (!binary.ok) { + await ctx.ui.notify(failureMessage("annotate", binary)); + return; + } + + const request: PluginAnnotateRequest = { + ...buildBaseRequest(cwd), + markdown: message, + filePath: "last-message", + mode: "annotate-last", + }; + const response = await (deps.runPluginAnnotate ?? runPluginAnnotate)( + binary.path, + request, + undefined, + runOptions(amp, ctx, cwd), + ); + + await handleAnnotateResult(ctx, response, { kind: "message" }); +} + +// ── Binary-client wiring ────────────────────────────────────────────────────── + +export function buildBaseRequest(cwd: string): { + origin: "amp"; + cwd: string; + sharingEnabled: boolean; + shareBaseUrl: string | undefined; + pasteApiUrl: string | undefined; +} { + return { + origin: RUNTIME, + cwd, + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }; +} + +function ensureBinary( + requiredFeatures: readonly PluginFeature[], + deps: BinaryClientDeps, +): EnsurePlannotatorBinaryResult { + return (deps.ensurePlannotatorBinary ?? ensurePlannotatorBinary)({ + requiredFeatures, + sourceRoot: findPlannotatorSourceRoot(import.meta.dir), + }); +} + +function runOptions(amp: PluginAPI, ctx: CommandContext, cwd: string): CommandRunOptions { + return { + // Plan/review/annotate sessions can stay open as long as the user needs; + // mirror OpenCode/Pi and never time the daemon out. + timeoutMs: null, + cwd, + env: buildEnv({ PLANNOTATOR_ORIGIN: RUNTIME, PLANNOTATOR_CWD: cwd }), + onSession: (session) => { + amp.logger.log(`[plannotator] session ready: ${session.url}`); + void ctx.ui.notify(`Plannotator link:\n${session.url}`); + }, + }; +} + +// ── Result handling ─────────────────────────────────────────────────────────── + +export async function handleReviewResult( + ctx: CommandContext, + response: PluginResponse, +): Promise { + if (!response.ok) { + await ctx.ui.notify(`Plannotator review failed.\n\n${response.error.message}`); + return; + } + + const result = response.result; + if (result.exit) return; + + const message = result.prompt ?? result.feedback; + if (!message || isNoActionFeedback(message)) { + await ctx.ui.notify(message?.trim() || "Review session closed without feedback."); + return; + } + + await appendFeedback(ctx, message); +} + +export async function handleAnnotateResult( + ctx: CommandContext, + response: PluginResponse, + options: { kind: "file"; filePath: string } | { kind: "message" }, +): Promise { + if (!response.ok) { + await ctx.ui.notify(`Plannotator annotate failed.\n\n${response.error.message}`); + return; + } + + const result = response.result; + if (result.exit || result.approved) { + await ctx.ui.notify("Annotation session closed."); + return; + } + + // The daemon composes a ready-to-send prompt; prefer it. Otherwise fall back + // to the raw feedback wrapped via the configurable per-runtime templates so + // `~/.plannotator/config.json` prompt overrides still apply. + const message = + result.prompt ?? + formatAnnotationFeedback({ decision: "annotated", feedback: result.feedback }, options) ?? + result.feedback; + + if (!message || isNoActionFeedback(message)) { + await ctx.ui.notify("Annotation session closed without feedback."); + return; + } + + await appendFeedback(ctx, message); +} + +async function appendFeedback(ctx: CommandContext, content: string): Promise { + if (!ctx.thread) { + await ctx.ui.notify("Plannotator produced feedback, but there is no active Amp thread."); + return; + } + + await ctx.thread.append([{ type: "user-message", content }]); +} + +function failureMessage( + mode: "review" | "annotate", + binary: Extract, +): string { + const missingExecutable = + binary.code === "missing-binary" || + binary.code === "incompatible-binary" || + binary.code === "install-failed" || + binary.code === "install-missing-binary"; + const installHint = missingExecutable ? `\n\nInstall the CLI first: ${INSTALL_URL}` : ""; + return `Plannotator ${mode} failed.\n\n${binary.message}${installHint}`; +} + +// ── Thread helpers ──────────────────────────────────────────────────────────── + export function extractTextFromThreadMessage(message: ThreadMessage): string { if (message.role !== "assistant") return ""; - return message.content - .filter((block) => block.type === "text" && block.text.trim()) - .map((block) => block.text.trim()) - .join("\n\n") - .trim(); + const parts: string[] = []; + for (const block of message.content) { + if (block.type !== "text") continue; + const text = typeof block.text === "string" ? block.text.trim() : ""; + if (text) parts.push(text); + } + return parts.join("\n\n").trim(); } -export function parseAnnotateDecision(raw: string): AnnotateDecision | null { - const trimmed = raw.trim(); - if (!trimmed) return { decision: "dismissed" }; +async function getLatestAssistantText(ctx: CommandContext): Promise { + if (!ctx.thread) return null; + + const latest = await ctx.thread.messages({ from: "end", limit: 1, roles: ["assistant"] }); + const latestText = latest.map(extractTextFromThreadMessage).find(Boolean); + if (latestText) return latestText; - try { - const parsed = JSON.parse(trimmed) as Partial; - if ( - parsed && - (parsed.decision === "approved" || - parsed.decision === "dismissed" || - parsed.decision === "annotated") - ) { - return { - decision: parsed.decision, - feedback: typeof parsed.feedback === "string" ? parsed.feedback : undefined, - }; - } - } catch { - return null; + const recent = await ctx.thread.messages({ from: "end", limit: 20, roles: ["assistant"] }); + for (let i = recent.length - 1; i >= 0; i -= 1) { + const text = extractTextFromThreadMessage(recent[i]); + if (text) return text; } return null; } +// ── Feedback formatting ─────────────────────────────────────────────────────── + +interface AnnotateDecision { + decision: "approved" | "dismissed" | "annotated"; + feedback?: string; +} + export function formatAnnotationFeedback( decision: AnnotateDecision, options: { kind: "file"; filePath: string } | { kind: "message" }, @@ -222,12 +386,12 @@ export function isNoActionFeedback(output: string): boolean { normalized === "" || normalized === "review session closed without feedback." || normalized === "annotation session closed." || - normalized === "approved." || - normalized === "the user approved." || normalized.includes("has no feedback") ); } +// ── Argument parsing ────────────────────────────────────────────────────────── + export function splitCommandArgs(input: string): string[] { const args: string[] = []; let current = ""; @@ -303,168 +467,7 @@ export function parseReviewTargetInput(target: string | undefined): string[] | n return target.trim() ? splitCommandArgs(target) : []; } -async function getLatestAssistantText(ctx: CommandContext): Promise { - if (!ctx.thread) return null; - - const latest = await ctx.thread.messages({ from: "end", limit: 1, roles: ["assistant"] }); - const latestText = latest.map(extractTextFromThreadMessage).find(Boolean); - if (latestText) return latestText; - - const recent = await ctx.thread.messages({ from: "end", limit: 20, roles: ["assistant"] }); - for (let i = recent.length - 1; i >= 0; i -= 1) { - const text = extractTextFromThreadMessage(recent[i]); - if (text) return text; - } - - return null; -} - -async function handleReviewResult(ctx: CommandContext, result: RunResult): Promise { - if (await notifyFailure(ctx, result, "review")) return; - - const output = result.stdout.trim(); - if (isNoActionFeedback(output)) { - await ctx.ui.notify(output || "Review session closed without feedback."); - return; - } - - await appendFeedback(ctx, output); -} - -async function handleAnnotateResult( - ctx: CommandContext, - result: RunResult, - options: { kind: "file"; filePath: string } | { kind: "message" }, -): Promise { - if (await notifyFailure(ctx, result, "annotate")) return; - - const decision = parseAnnotateDecision(result.stdout); - if (decision?.decision === "approved") { - await ctx.ui.notify("Approved."); - return; - } - if (decision?.decision === "dismissed") { - await ctx.ui.notify("Annotation session closed."); - return; - } - - const feedback = decision - ? formatAnnotationFeedback(decision, options) - : result.stdout.trim(); - - if (!feedback || isNoActionFeedback(feedback)) { - await ctx.ui.notify("Annotation session closed without feedback."); - return; - } - - await appendFeedback(ctx, feedback); -} - -async function appendFeedback(ctx: CommandContext, content: string): Promise { - if (!ctx.thread) { - await ctx.ui.notify("Plannotator produced feedback, but there is no active Amp thread."); - return; - } - - await ctx.thread.append([{ type: "user-message", content }]); -} - -async function notifyFailure( - ctx: CommandContext, - result: RunResult, - mode: "review" | "annotate", -): Promise { - if (!result.error && result.status === 0) return false; - - const details = [result.error, result.stderr.trim(), result.stdout.trim()] - .filter(Boolean) - .join("\n") - .trim(); - const missingExecutable = /\bENOENT\b/i.test(details) || - /executable not found/i.test(details) || - /command not found/i.test(details); - const installHint = missingExecutable - ? `\n\nInstall the CLI first: ${INSTALL_URL}` - : ""; - - await ctx.ui.notify(`Plannotator ${mode} failed.${details ? `\n\n${details}` : ""}${installHint}`); - return true; -} - -async function runPlannotator( - amp: PluginAPI, - ctx: CommandContext, - args: string[], - options: { stdin?: string; runtime?: PlannotatorRuntime } = {}, -): Promise { - const cwd = await resolveCwd(ctx); - const runtime = options.runtime ?? await getPlannotatorRuntime(); - const readyFile = runtime.features.readyFile - ? join(tmpdir(), `plannotator-amp-${process.pid}-${Date.now()}-${randomUUID()}.jsonl`) - : null; - const command = [...runtime.command, ...args]; - const env = buildEnv(buildPlannotatorEnv(cwd, readyFile)); - - let proc: Bun.Subprocess<"ignore" | "pipe", "pipe", "pipe">; - try { - proc = Bun.spawn(command, { - cwd, - env, - stdin: options.stdin ? "pipe" : "ignore", - stdout: "pipe", - stderr: "pipe", - }); - } catch (error) { - return { - status: 1, - stdout: "", - stderr: "", - error: error instanceof Error ? error.message : String(error), - }; - } - - if (options.stdin && proc.stdin) { - proc.stdin.write(options.stdin); - proc.stdin.end(); - } - - const exitState: ExitState = { done: false }; - const stdoutPromise = new Response(proc.stdout).text(); - const stderrPromise = collectStderr(amp, ctx, proc.stderr); - const exitedPromise = proc.exited.finally(() => { - exitState.done = true; - }); - - let readyPromise: Promise | null = null; - if (readyFile) { - readyPromise = waitForReadyFile(readyFile, exitState); - const readyResult = await readyPromise; - if (readyResult === "timeout") { - try { - proc.kill(); - } catch { - // Process may already have exited. - } - } - } - - const status = await exitedPromise; - const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); - - try { - if (readyFile) unlinkSync(readyFile); - } catch { - // Temporary ready file may not exist if the command failed early. - } - - const readyTimedOut = readyPromise ? (await readyPromise) === "timeout" : false; - return { - status, - stdout, - stderr, - ...(readyTimedOut ? { error: "Timed out waiting for Plannotator to publish its browser URL." } : {}), - }; -} +// ── Workspace resolution ────────────────────────────────────────────────────── export async function resolveCwd(ctx: CommandContext): Promise { const explicitCwd = normalizeDirectory(process.env.PLANNOTATOR_CWD); @@ -536,14 +539,6 @@ function fileUrlToPath(value: string): string { : pathname; } -export function buildPlannotatorEnv(cwd: string, readyFile: string | null): Record { - return { - PLANNOTATOR_ORIGIN: RUNTIME, - PLANNOTATOR_CWD: cwd, - ...(readyFile ? { PLANNOTATOR_READY_FILE: readyFile } : {}), - }; -} - function normalizeDirectory(value: string | undefined): string | null { const candidate = value?.trim(); if (!candidate || candidate === "undefined" || candidate === "null") return null; @@ -555,327 +550,31 @@ function normalizeDirectory(value: string | undefined): string | null { } } -export function buildEnv(extra: Record): Record { - const env: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (typeof value === "string") env[key] = value; - } - delete env.BUN_BE_BUN; - return { ...env, ...extra }; -} - -async function collectStderr( - amp: PluginAPI, - ctx: CommandContext, - stream: ReadableStream, -): Promise { - const decoder = new TextDecoder(); - const reader = stream.getReader(); - const seenUrls = new Set(); - let output = ""; - let lineBuffer = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - const text = decoder.decode(value, { stream: true }); - output += text; - lineBuffer += text; - - const lines = lineBuffer.split(/\r?\n/); - lineBuffer = lines.pop() ?? ""; - for (const line of lines) { - await notifyUrls(ctx, line, seenUrls); - } - } - - const tail = decoder.decode(); - output += tail; - if (tail) lineBuffer += tail; - if (lineBuffer) await notifyUrls(ctx, lineBuffer, seenUrls); - - if (output.trim()) amp.logger.log(output.trim()); - return output; -} - -async function notifyUrls( - ctx: CommandContext, - text: string, - seenUrls: Set, -): Promise { - const matches = text.match(/https?:\/\/[^\s)]+/g) ?? []; - for (const rawUrl of matches) { - const url = rawUrl.replace(/[.,;]+$/, ""); - if (seenUrls.has(url)) continue; - seenUrls.add(url); - await ctx.ui.notify(`Plannotator link:\n${url}`); - } -} - -async function waitForReadyFile( - readyFile: string, - exitState: ExitState, -): Promise { - const deadline = Date.now() + READY_TIMEOUT_MS; - const seen = new Set(); - - while (Date.now() < deadline) { - if (existsSync(readyFile)) { - const lines = readFileSync(readyFile, "utf8").split(/\r?\n/).filter(Boolean); - for (const line of lines) { - let payload: { url?: unknown }; - try { - payload = JSON.parse(line) as { url?: unknown }; - } catch { - // Keep polling; the writer may still be appending the line. - continue; - } - - if (typeof payload.url !== "string" || seen.has(payload.url)) continue; - seen.add(payload.url); - return "ready"; - } - } - - if (exitState.done) return "exited"; - await sleep(100); - } - - return "timeout"; -} - -async function getPlannotatorRuntime(): Promise { - runtimePromise ??= resolvePlannotatorRuntime(); - return runtimePromise; -} - -async function resolvePlannotatorRuntime(): Promise { - const explicitSource = process.env.PLANNOTATOR_AMP_SOURCE_ENTRY; - const sourceEntry = explicitSource - ? resolve(explicitSource) - : process.env.PLANNOTATOR_AMP_USE_SOURCE === "1" - ? findSourceEntry(import.meta.dir) - : null; - - if (sourceEntry && existsSync(sourceEntry)) { - return { - command: [getBunExecutable(), sourceEntry], - source: "source", - version: "source", - features: { readyFile: true, stdinLast: true }, - }; - } - - const { command, version } = resolvePlannotatorCommand(); - return { - command, - source: "cli", - version, - features: { - readyFile: semverGte(version, MIN_READY_FILE_VERSION), - stdinLast: semverGte(version, MIN_STDIN_LAST_VERSION), - }, - }; -} - -function resolvePlannotatorCommand(): { command: string[]; version: string | null } { - const candidates = getPlannotatorCommandCandidates(); - let fallback = candidates[candidates.length - 1] ?? ["plannotator"]; - - for (const command of candidates) { - const executable = command[0]; - if (!executable) continue; - - if (isPathLike(executable)) { - if (!existsSync(executable)) continue; - const version = detectPlannotatorVersion(command); - return { command, version }; - } - - fallback = command; - const version = detectPlannotatorVersion(command); - if (version) return { command, version }; - } - - return { command: fallback, version: detectPlannotatorVersion(fallback) }; -} - -export function getPlannotatorCommandCandidates( - options: { - env?: Record; - home?: string; - pluginDir?: string; - platform?: string; - } = {}, -): string[][] { - const env = options.env ?? process.env; - const homes = getHomeDirectoryCandidates(env, options.home, options.pluginDir ?? import.meta.dir); - const platform = options.platform ?? process.platform; - const candidates: string[][] = []; - - const explicitBin = normalizeExecutablePath(env.PLANNOTATOR_BIN); - if (explicitBin) candidates.push([explicitBin]); - - if (platform === "win32") { - const localAppData = normalizeExecutablePath(env.LOCALAPPDATA); - if (localAppData) candidates.push([join(localAppData, "plannotator", "plannotator.exe")]); - - for (const home of homes) { - candidates.push([join(home, ".local", "bin", "plannotator.exe")]); - } - } else { - for (const home of homes) { - candidates.push([join(home, ".local", "bin", "plannotator")]); - } - } - - candidates.push(["plannotator"]); - return dedupeCommands(candidates); +function getAmpCacheDir(): string { + const cacheHome = normalizeOptionalPath(process.env.XDG_CACHE_HOME); + return cacheHome ? join(cacheHome, "amp") : join(homedir(), ".cache", "amp"); } -function normalizeExecutablePath(value: string | undefined): string | null { +function normalizeOptionalPath(value: string | undefined): string | null { const candidate = value?.trim(); if (!candidate || candidate === "undefined" || candidate === "null") return null; return candidate; } -function getHomeDirectoryCandidates( - env: Record, - explicitHome: string | undefined, - pluginDir: string, -): string[] { - return dedupeStrings([ - normalizeExecutablePath(explicitHome), - normalizeExecutablePath(env.HOME), - normalizeExecutablePath(env.USERPROFILE), - deriveHomeFromAmpPluginDir(pluginDir), - explicitHome === undefined ? normalizeExecutablePath(homedir()) : null, - ]); -} - -function deriveHomeFromAmpPluginDir(pluginDir: string): string | null { - const pluginsDir = resolve(pluginDir); - const ampDir = dirname(pluginsDir); - const configDir = dirname(ampDir); - - if ( - basename(pluginsDir) === "plugins" && - basename(ampDir) === "amp" && - basename(configDir) === ".config" - ) { - return dirname(configDir); - } - - return null; -} - -function getAmpCacheDir(): string { - const cacheHome = normalizeExecutablePath(process.env.XDG_CACHE_HOME); - return cacheHome ? join(cacheHome, "amp") : join(homedir(), ".cache", "amp"); -} - -function dedupeCommands(commands: string[][]): string[][] { - const seen = new Set(); - const deduped: string[][] = []; - for (const command of commands) { - const key = command.join("\0"); - if (seen.has(key)) continue; - seen.add(key); - deduped.push(command); - } - return deduped; -} - -function dedupeStrings(values: Array): string[] { - const seen = new Set(); - const deduped: string[] = []; - for (const value of values) { - if (!value || seen.has(value)) continue; - seen.add(value); - deduped.push(value); - } - return deduped; -} - -function isPathLike(command: string): boolean { - return command.includes("/") || command.includes("\\"); -} - -function findSourceEntry(startDir: string): string | null { - const root = findRepoRoot(startDir); - if (!root) return null; - - const sourceEntry = join(root, "apps", "hook", "server", "index.ts"); - return existsSync(sourceEntry) ? sourceEntry : null; -} - -function findRepoRoot(startDir: string): string | null { - let dir = resolve(startDir); - - while (true) { - const packageJsonPath = join(dir, "package.json"); - if (existsSync(packageJsonPath)) { - try { - const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }; - if (pkg.name === "plannotator") return dir; - } catch { - // Ignore malformed package.json while walking upward. - } - } - - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -function getBunExecutable(): string { - const candidates = [process.execPath, Bun.argv[0], Bun.which?.("bun"), "bun"]; - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - const value = candidate.trim(); - if (!value || value === "undefined" || value === "null") continue; - return value; - } +// ── Environment ─────────────────────────────────────────────────────────────── - return "bun"; -} - -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - -function detectPlannotatorVersion(command: string[]): string | null { - try { - const result = Bun.spawnSync([...command, "--version"], { - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode !== 0) return null; - - const output = new TextDecoder().decode(result.stdout).trim(); - const match = output.match(/\b(\d+\.\d+\.\d+)\b/); - return match?.[1] ?? null; - } catch { - return null; +export function buildEnv(extra: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") env[key] = value; } + // Amp runs the plugin under `bun --bun`; BUN_BE_BUN would force the spawned + // plannotator binary into Bun mode too. Scrub it so the binary runs natively. + delete env.BUN_BE_BUN; + return { ...env, ...extra }; } -function semverGte(actual: string | null, minimum: string): boolean { - if (!actual) return false; - const actualParts = actual.split(".").map((part) => Number(part)); - const minimumParts = minimum.split(".").map((part) => Number(part)); - - for (let i = 0; i < 3; i += 1) { - const actualPart = actualParts[i] ?? 0; - const minimumPart = minimumParts[i] ?? 0; - if (actualPart > minimumPart) return true; - if (actualPart < minimumPart) return false; - } - - return true; -} +// ── Prompt config ───────────────────────────────────────────────────────────── type PromptConfig = { prompts?: { diff --git a/apps/amp-plugin/tsconfig.json b/apps/amp-plugin/tsconfig.json new file mode 100644 index 000000000..ac275aa81 --- /dev/null +++ b/apps/amp-plugin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "types": ["node", "bun-types"] + }, + "include": ["*.ts"], + "exclude": ["**/*.test.ts", "dist/**"] +} diff --git a/apps/droid-plugin/README.md b/apps/droid-plugin/README.md index d1c728507..d99895fb3 100644 --- a/apps/droid-plugin/README.md +++ b/apps/droid-plugin/README.md @@ -5,7 +5,6 @@ Plannotator's Droid plugin ships the manual slash-command workflow only: - `/plannotator-review [PR_URL]` (no args reviews local changes) - `/plannotator-annotate ` - `/plannotator-last` -- `/plannotator-archive` It does not attempt plan-mode interception or host-level planning integration. diff --git a/apps/droid-plugin/commands/plannotator-archive.js b/apps/droid-plugin/commands/plannotator-archive.js deleted file mode 100755 index 04c5e90ed..000000000 --- a/apps/droid-plugin/commands/plannotator-archive.js +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node - -const { exitWithFailure, runPlannotator } = require("../lib/run-plannotator"); - -const result = runPlannotator(["archive", ...process.argv.slice(2)]); - -if (result.error || result.status !== 0) { - exitWithFailure(result, "plannotator archive"); -} - -process.stdout.write("Archive browsing finished.\n"); diff --git a/apps/frontend/.oxfmtignore b/apps/frontend/.oxfmtignore new file mode 100644 index 000000000..928e4ae2f --- /dev/null +++ b/apps/frontend/.oxfmtignore @@ -0,0 +1,2 @@ +dist +src/routeTree.gen.ts diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 000000000..8de677c95 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,33 @@ +# @plannotator/frontend + +Production frontend SPA for the Plannotator daemon runtime. Serves all session types: plan review, code review, annotate, and setup-goal. + +## Shape + +- `src/routes` is only TanStack Router wiring. +- `src/daemon` owns the typed daemon API client and contracts. +- `src/sessions` owns session ids, session state, the dashboard, and mode dispatch. +- `src/plan`, `src/review`, `src/annotate`, and `src/setup-goal` own product views. +- `src/testing` owns contract fixtures and browser helpers. + +The shell talks to session APIs through `/s/:sessionId/api`, never root `/api`. + +The build is intentionally single-file HTML for daemon serving. Separate static asset +routes are deferred until the full UI migration needs code splitting or cacheable chunks. + +## Commands + +```bash +bun run --cwd apps/frontend dev +bun run --cwd apps/frontend build +bun run --cwd apps/frontend check +bun run --cwd apps/frontend test:browser +``` + +Or from the repo root: + +```bash +bun run dev:frontend +bun run build:frontend +bun run check:frontend +``` diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 000000000..670f0034e --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + Plannotator + + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 000000000..c3e165f09 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,62 @@ +{ + "name": "@plannotator/frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && bun run scripts/verify-single-file-build.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", + "fmt": "oxfmt --ignore-path .oxfmtignore --write .", + "fmt:check": "oxfmt --ignore-path .oxfmtignore --check .", + "test": "vitest run --passWithNoTests", + "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test" + }, + "dependencies": { + "@fontsource-variable/geist-mono": "5.2.7", + "@fontsource-variable/instrument-sans": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", + "@plannotator/code-review": "workspace:*", + "@plannotator/plan-review": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-router": "^1.141.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "immer": "^10.2.0", + "lucide-react": "^1.14.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.141.0", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "oxfmt": "^0.17.0", + "oxlint": "^1.31.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "vitest": "^4.0.16" + } +} diff --git a/apps/frontend/scripts/verify-single-file-build.ts b/apps/frontend/scripts/verify-single-file-build.ts new file mode 100644 index 000000000..e3965fb40 --- /dev/null +++ b/apps/frontend/scripts/verify-single-file-build.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync } from "fs"; +import { join, relative } from "path"; + +const distDir = join(import.meta.dirname, "..", "dist"); +const indexPath = join(distDir, "index.html"); + +function listFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +if (!existsSync(indexPath)) { + throw new Error("Expected apps/frontend/dist/index.html to exist after build."); +} + +const html = readFileSync(indexPath, "utf-8"); + +const outputFiles = listFiles(distDir) + .map((file) => relative(distDir, file)) + .sort(); +const extraFiles = outputFiles.filter((file) => file !== "index.html"); + +if (extraFiles.length > 0) { + throw new Error( + `Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`, + ); +} + +const htmlWithoutInlineCode = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + +const externalScriptPattern = /]*\bsrc=["'][^"']+["']/i; +const externalLinkPatterns = [ + /]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i, + /]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i, +]; + +if ( + externalScriptPattern.test(html) || + externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode)) +) { + throw new Error("Frontend daemon shell build must inline scripts and styles."); +} + +console.log("Verified single-file frontend shell build."); diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx new file mode 100644 index 000000000..071f13d0d --- /dev/null +++ b/apps/frontend/src/app/Layout.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect } from "react"; +import { Outlet, useMatchRoute } from "@tanstack/react-router"; +import { Toaster } from "sonner"; +import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AppSidebar } from "../components/sidebar/AppSidebar"; +import { SidebarPeek } from "../components/sidebar/SidebarPeek"; +import { AddProjectDialog } from "../components/landing/AddProjectDialog"; +import { AppSettingsDialog } from "../components/settings/AppSettingsDialog"; +import { SessionSurface } from "../components/sessions/SessionSurface"; +import { appStore } from "../stores/app-store"; +import { setGlobalFetchBase } from "@plannotator/ui/utils/api"; +import { useDaemonEvents } from "../daemon/events/use-daemon-events"; + +setGlobalFetchBase("/daemon"); +import { projectStore } from "../stores/project-store"; +import { useAppStore } from "../stores/app-store"; + +function LayoutContent() { + const addProjectOpen = useAppStore((s) => s.addProjectOpen); + const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen); + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const matchRoute = useMatchRoute(); + const { open: sidebarOpen } = useSidebar(); + + const { reportActiveSession } = useDaemonEvents(); + + useEffect(() => { + void projectStore.getState().fetchProjects(); + }, []); + + const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + + useEffect(() => { + reportActiveSession(isOnSession ? activeSessionId : null); + }, [reportActiveSession, isOnSession, activeSessionId]); + const showLanding = !isOnSession; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + const current = appStore.getState().settingsOpen; + appStore.getState().setSettingsOpen(!current); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + return ( + <> + + +
+
+ +
+ + {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => { + const isActive = sessionId === activeSessionId && isOnSession; + return ( +
+ +
+ ); + })} +
+ + + + + ); +} + +export function Layout() { + const matchRoute = useMatchRoute(); + const initiallyOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + + return ( + + + + + + ); +} diff --git a/apps/frontend/src/app/router.tsx b/apps/frontend/src/app/router.tsx new file mode 100644 index 000000000..3693e34c7 --- /dev/null +++ b/apps/frontend/src/app/router.tsx @@ -0,0 +1,25 @@ +import { createRouter } from "@tanstack/react-router"; +import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client"; +import { routeTree } from "../routeTree.gen"; + +export interface AppRouterContext { + daemonClient: DaemonApiClient; +} + +export function createAppRouter( + context: AppRouterContext = { daemonClient: createDaemonApiClient() }, +) { + return createRouter({ + routeTree, + context, + defaultPreload: "intent", + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/frontend/src/assets/sprite_package_sidebar/sprite.png b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png new file mode 100644 index 000000000..a209d60cc Binary files /dev/null and b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png differ diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx new file mode 100644 index 000000000..d1c6100bf --- /dev/null +++ b/apps/frontend/src/components/landing/AddProjectDialog.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Folder, ChevronRight, X, CornerDownLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { useProjectStore } from "../../stores/project-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import type { DirectoryEntry, ProjectEntry } from "../../daemon/contracts"; + +interface AddProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddProjectDialog({ open, onOpenChange }: AddProjectDialogProps) { + const [query, setQuery] = useState("~"); + const [resolvedPath, setResolvedPath] = useState(""); + const [dirs, setDirs] = useState([]); + const [loading, setLoading] = useState(false); + const [adding, setAdding] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const projects = useProjectStore((s) => s.projects); + const addProject = useProjectStore((s) => s.addProject); + const inputRef = useRef(null); + const listRef = useRef(null); + + const recentProjects = projects.slice(0, 5); + + const fetchDirs = useCallback(async (path: string) => { + setLoading(true); + const result = await daemonApiClient.listDirectories(path); + if (result.ok) { + setResolvedPath(result.data.path); + setDirs(result.data.dirs); + } else { + setDirs([]); + } + setLoading(false); + setActiveIndex(0); + }, []); + + useEffect(() => { + if (!open) return; + setQuery("~"); + setDirs([]); + setResolvedPath(""); + setActiveIndex(0); + fetchDirs("~"); + // Input focus is handled by DialogContent's onOpenAutoFocus. + }, [open, fetchDirs]); + + useEffect(() => { + if (!open) return; + const timer = setTimeout(() => { + if (query.trim()) fetchDirs(query.trim()); + }, 150); + return () => clearTimeout(timer); + }, [query, open, fetchDirs]); + + const handleSelect = useCallback( + async (path: string) => { + setAdding(true); + const result = await addProject(path); + setAdding(false); + if (result) { + onOpenChange(false); + } + }, + [addProject, onOpenChange], + ); + + const handleNavigate = useCallback( + (path: string) => { + setQuery(path); + fetchDirs(path); + }, + [fetchDirs], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const totalItems = recentProjects.length + dirs.length; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % totalItems); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + totalItems) % totalItems); + } else if (e.key === "Tab" && !e.shiftKey && dirs.length > 0) { + e.preventDefault(); + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleNavigate(dirs[dirIndex].path); + } else if (dirs.length > 0) { + handleNavigate(dirs[0].path); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (activeIndex < recentProjects.length) { + handleSelect(recentProjects[activeIndex].cwd); + } else { + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleSelect(dirs[dirIndex].path); + } else if (resolvedPath) { + handleSelect(resolvedPath); + } + } + } + // Escape is handled by Radix Dialog (onEscapeKeyDown → onOpenChange(false)). + }, + [activeIndex, dirs, recentProjects, resolvedPath, handleNavigate, handleSelect], + ); + + useEffect(() => { + const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + return ( + + { + e.preventDefault(); + inputRef.current?.focus(); + }} + > + Add a project + + Search for a directory to add as a project + +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="~/work/project or search…" + autoComplete="off" + spellCheck={false} + className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-[13px]" + /> + {adding && Adding…} + +
+ +
+ {recentProjects.length > 0 && ( +
+ + Recent + + {recentProjects.map((project, i) => ( + handleSelect(project.cwd)} + onHover={() => setActiveIndex(i)} + /> + ))} +
+ )} + +
+ {recentProjects.length > 0 && dirs.length > 0 && ( + + Directories + + )} + {dirs.map((dir, i) => { + const idx = recentProjects.length + i; + return ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(idx)} + /> + ); + })} + {!loading && dirs.length === 0 && recentProjects.length === 0 && ( +
+ No directories found +
+ )} +
+
+ +
+ + select + + + Tab navigate into + + + Esc close + +
+
+
+ ); +} + +function ProjectRow({ + project, + active, + index, + onSelect, + onHover, +}: { + project: ProjectEntry; + active: boolean; + index: number; + onSelect: () => void; + onHover: () => void; +}) { + return ( + + ); +} + +function DirectoryRow({ + dir, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx new file mode 100644 index 000000000..885d317b6 --- /dev/null +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -0,0 +1,762 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { + Code2, + Folder, + FolderPlus, + ChevronRight, + ChevronDown, + Trash2, +} from "lucide-react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import { ASCII_BANNER } from "./ascii-banner"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useProjectStore, projectStore } from "../../stores/project-store"; +import { GitDashboard } from "./git-dashboard/GitDashboard"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import { buildStacks, type PRStack } from "./buildStacks"; +import type { + ProjectEntry, + PRListItem, + SessionSummary, + WorktreeEntry, +} from "../../daemon/contracts"; + +interface LandingPageProps { + onAddProject: () => void; +} + +interface Selection { + key: string; + cwd: string; + label: string; + prUrl?: string; +} + +function selectionKey(sel: Omit): string { + return sel.prUrl ?? sel.cwd; +} + +export function LandingPage({ onAddProject }: LandingPageProps) { + const projects = useProjectStore((s) => s.projects); + const sessions = useDaemonEventStore((s) => s.sessions); + const [selections, setSelections] = useState>(new Map()); + useEffect(() => { + const cwds = new Set(projects.map((p) => p.cwd)); + setSelections((prev) => { + const next = new Map(); + for (const [k, sel] of prev) { + if (cwds.has(sel.cwd)) next.set(k, sel); + } + return next.size === prev.size ? prev : next; + }); + }, [projects]); + const [loading, setLoading] = useState(null); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); + const navigate = useNavigate(); + + const toggleSelection = useCallback((sel: Omit) => { + setSelections((prev) => { + const key = selectionKey(sel); + const next = new Map(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { ...sel, key }); + } + return next; + }); + }, []); + + const selectionCount = selections.size; + + const handleAction = useCallback( + async (action: "review") => { + if (selectionCount === 0) return; + setLoading(action); + const items = [...selections.values()]; + + const results = await Promise.allSettled( + items.map(async (sel) => { + const result = await daemonApiClient.createReviewSession(sel.cwd, sel.prUrl); + return { sel, result }; + }), + ); + setLoading(null); + + let firstSessionId: string | null = null; + let successCount = 0; + const failures: { label: string; message: string }[] = []; + + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value.result.ok) { + successCount++; + if (!firstSessionId) firstSessionId = outcome.value.result.data.session.id; + } else { + const label = outcome.status === "fulfilled" ? outcome.value.sel.label : "Unknown"; + const message = + outcome.status === "fulfilled" && !outcome.value.result.ok + ? outcome.value.result.error.message + : outcome.status === "rejected" + ? String(outcome.reason) + : "Unknown error"; + failures.push({ label, message }); + } + } + + if (firstSessionId) { + setSelections(new Map()); + void navigate({ to: "/s/$sessionId", params: { sessionId: firstSessionId } }); + if (successCount > 1) { + toast.success(`Launched ${successCount} sessions`); + } + } + + for (const fail of failures) { + toast.error(fail.label, { description: fail.message }); + } + }, + [selections, selectionCount, navigate], + ); + + return ( +
+
+
+
+ +
+
+
+
+
+ + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( +
+ {projects.length > 0 && ( +
+
+ + Select project + + +
+ + +
+ + Launch + +
+ + +
+
+
+ )} + + {sessions.length > 0 && ( +
+
+ Active sessions +
+ +
+ )} + + {projects.length === 0 && ( + + )} +
+ )} +
+
+
+
+ setViewIndex(0)} /> +
+
+
+
+
+ ); +} + +function ProjectTable({ + projects, + selections, + onToggle, +}: { + projects: ProjectEntry[]; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const topLevel = projects.filter((p) => !p.parentCwd); + const worktreeChildren = (parentCwd: string) => projects.filter((p) => p.parentCwd === parentCwd); + + return ( +
+ {topLevel.map((project, i) => { + const children = worktreeChildren(project.cwd); + return ( + + ); + })} +
+ ); +} + +function ProjectNode({ + project, + children, + isFirst, + selections, + onToggle, +}: { + project: ProjectEntry; + children: ProjectEntry[]; + isFirst: boolean; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [worktreesFetched, setWorktreesFetched] = useState(false); + const [prs, setPrs] = useState([]); + const [prPlatform, setPrPlatform] = useState(null); + const [prDefaultBranch, setPrDefaultBranch] = useState("main"); + const [prError, setPrError] = useState(null); + const [prsLoading, setPrsLoading] = useState(false); + const [prsFetchedAt, setPrsFetchedAt] = useState(0); + const hasChildren = children.length > 0; + + useEffect(() => { + if (!expanded || worktreesFetched) return; + setWorktreesFetched(true); + daemonApiClient.listWorktrees(project.cwd).then((result) => { + if (result.ok) { + setWorktrees(result.data.worktrees.filter((wt) => wt.path !== project.cwd)); + } + }); + }, [expanded, project.cwd, worktreesFetched]); + + useEffect(() => { + if (!expanded) return; + const stale = !prsFetchedAt || Date.now() - prsFetchedAt > 30_000; + if (!stale || prsLoading) return; + setPrsLoading(true); + daemonApiClient.listPRs(project.cwd).then((result) => { + if (result.ok) { + setPrs(result.data.prs); + setPrPlatform(result.data.platform); + if (result.data.defaultBranch) setPrDefaultBranch(result.data.defaultBranch); + setPrError(result.data.error ?? null); + } + setPrsLoading(false); + setPrsFetchedAt(Date.now()); + }); + }, [expanded, project.cwd, prsFetchedAt, prsLoading]); + + const hasWorktrees = hasChildren || worktrees.length > 0; + const isSelected = selections.has(project.cwd); + + const handleRemove = useCallback(async () => { + const ok = window.confirm( + `Remove "${project.name}"?\n\nThis will cancel active sessions and delete plan history for this project.`, + ); + if (!ok) return; + const removed = await projectStore.getState().removeProject(project.cwd, true); + if (removed) { + toast.success(`Removed ${project.name}`); + } else { + toast.error(`Failed to remove ${project.name}`); + } + }, [project.name, project.cwd]); + + return ( + + +
+
+ + +
+ + {expanded && ( +
+ + + + PRs + + + Worktrees + + + + + + + + + +
+ )} +
+
+ + + + + Remove project + + + +
+ ); +} + +function PRRow({ + pr, + projectCwd, + projectName, + selections, + onToggle, +}: { + pr: PRListItem; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + return ( + + ); +} + +function StackIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function StackGroup({ + stack, + projectCwd, + projectName, + selections, + onToggle, +}: { + stack: PRStack; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {stack.prs.map((pr) => ( + + ))} +
+ )} +
+ ); +} + +function PRList({ + prs, + loading, + error, + platform, + defaultBranch, + projectCwd, + projectName, + selections, + onToggle, +}: { + prs: PRListItem[]; + loading: boolean; + error: string | null; + platform: string | null; + defaultBranch: string; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [showAll, setShowAll] = useState(false); + const visible = useMemo( + () => (showAll ? prs : prs.filter((pr) => pr.state === "open")), + [prs, showAll], + ); + const hiddenCount = prs.length - visible.length; + const { stacks, loose } = useMemo(() => buildStacks(visible), [visible]); + + if (loading) { + return
Loading PRs…
; + } + if (error === "no-remote") { + return
No git remote detected
; + } + if (error === "no-cli") { + return ( +
+ {platform === "gitlab" ? "GitLab CLI (glab)" : "GitHub CLI (gh)"} not installed +
+ ); + } + if (error === "auth-failed") { + return ( +
+ {platform === "gitlab" ? "glab" : "gh"} not authenticated — run{" "} + + {platform === "gitlab" ? "glab" : "gh"} auth login + +
+ ); + } + if (error === "fetch-failed") { + return ( +
+ Failed to load {platform === "gitlab" ? "merge requests" : "pull requests"} +
+ ); + } + if (visible.length === 0 && !showAll) { + return ( +
+ No open pull requests + {hiddenCount > 0 && ( + <> + {" · "} + + + )} +
+ ); + } + + return ( +
+ {stacks.map((stack) => ( + + ))} + {loose.map((pr) => ( + + ))} + {!showAll && hiddenCount > 0 && ( + + )} +
+ ); +} + +function WorktreeList({ + children, + worktrees, + hasWorktrees, + projectName, + selections, + onToggle, +}: { + children: ProjectEntry[]; + worktrees: WorktreeEntry[]; + hasWorktrees: boolean; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + if (!hasWorktrees) { + return
No worktrees
; + } + + const allWorktrees: { path: string; branch: string }[] = []; + for (const child of children) { + allWorktrees.push({ path: child.cwd, branch: child.branch ?? child.name }); + } + for (const wt of worktrees) { + if (!children.some((c) => c.cwd === wt.path)) { + allWorktrees.push({ path: wt.path, branch: wt.branch ?? "detached" }); + } + } + + return ( +
+ {allWorktrees.map((wt) => ( + + ))} +
+ ); +} + +function SessionList({ sessions }: { sessions: SessionSummary[] }) { + return ( +
+ {sessions.map((session, i) => { + const meta = getSessionModeMeta(session.mode); + const Icon = meta.icon; + return ( + 0 && "border-t border-border", + "text-foreground hover:bg-surface-1", + )} + > + + + {formatSessionLabel(session.label, session.mode)} + + {meta.label} + + ); + })} +
+ ); +} + +function EmptyState({ onAddProject }: { onAddProject: () => void }) { + return ( +
+

No projects yet

+

+ Projects appear automatically when an agent creates a session, or you can add one manually. +

+ +
+ ); +} diff --git a/apps/frontend/src/components/landing/ascii-banner.ts b/apps/frontend/src/components/landing/ascii-banner.ts new file mode 100644 index 000000000..4b096f41f --- /dev/null +++ b/apps/frontend/src/components/landing/ascii-banner.ts @@ -0,0 +1,2 @@ +export const ASCII_BANNER = + " _ __ ,---. .-._ .-._ _,.---._ ,--.--------. ,---. ,--.--------. _,.---._ \n .-`.' ,`. _.-. .--.' \\ /==/ \\ .-._/==/ \\ .-._ ,-.' , - `. /==/, - , -\\.--.' \\ /==/, - , -\\,-.' , - `. .-.,.---. \n /==/, - \\.-,.'| \\==\\-/\\ \\ |==|, \\/ /, /==|, \\/ /, /==/_, , - \\\\==\\.-. - ,-./\\==\\-/\\ \\\\==\\.-. - ,-./==/_, , - \\ /==/ ` \\ \n|==| _ .=. |==|, | /==/-|_\\ | |==|- \\| ||==|- \\| |==| .=. |`--`\\==\\- \\ /==/-|_\\ |`--`\\==\\- \\ |==| .=. |==|-, .=., | \n|==| , '=',|==|- | \\==\\, - \\ |==| , | -||==| , | -|==|_ : ;=: - | \\==\\_ \\ \\==\\, - \\ \\==\\_ \\|==|_ : ;=: - |==| '=' / \n|==|- '..'|==|, | /==/ - ,| |==| - _ ||==| - _ |==| , '=' | |==|- | /==/ - ,| |==|- ||==| , '=' |==|- , .' \n|==|, | |==|- `-._/==/- /\\ - \\|==| /\\ , ||==| /\\ , |\\==\\ - ,_ / |==|, | /==/- /\\ - \\ |==|, | \\==\\ - ,_ /|==|_ . ,'. \n/==/ - | /==/ - , ,|==\\ _.\\=\\.-'/==/, | |- |/==/, | |- | '.='. - .' /==/ -/ \\==\\ _.\\=\\.-' /==/ -/ '.='. - .' /==/ /\\ , ) \n`--`---' `--`-----' `--` `--`./ `--``--`./ `--` `--`--'' `--`--` `--` `--`--` `--`--'' `--`-`--`--' "; diff --git a/apps/frontend/src/components/landing/buildStacks.test.ts b/apps/frontend/src/components/landing/buildStacks.test.ts new file mode 100644 index 000000000..1bc3099b0 --- /dev/null +++ b/apps/frontend/src/components/landing/buildStacks.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "vitest"; +import { buildStacks } from "./buildStacks"; +import type { PRListItem } from "../../daemon/contracts"; + +function pr({ + number, + head, + base, + state = "open", +}: { + number: number; + head: string; + base: string; + state?: PRListItem["state"]; +}): PRListItem { + return { + id: `pr-${number}`, + number, + title: `PR #${number}`, + author: "tater", + url: `https://example.com/pr/${number}`, + baseBranch: base, + headBranch: head, + state, + }; +} + +/** Every distinct ordering of a list, for permutation-invariance checks. */ +function permutations(items: T[]): T[][] { + if (items.length <= 1) return [items]; + const result: T[][] = []; + for (let i = 0; i < items.length; i++) { + const rest = [...items.slice(0, i), ...items.slice(i + 1)]; + for (const perm of permutations(rest)) { + result.push([items[i], ...perm]); + } + } + return result; +} + +// A 3-deep stack: A(base=main) ← B(base=A) ← C(base=B) +const A = pr({ number: 1, head: "a", base: "main" }); +const B = pr({ number: 2, head: "b", base: "a" }); +const C = pr({ number: 3, head: "c", base: "b" }); + +// A 4-deep stack: W ← X ← Y ← Z +const W = pr({ number: 10, head: "w", base: "main" }); +const X = pr({ number: 11, head: "x", base: "w" }); +const Y = pr({ number: 12, head: "y", base: "x" }); +const Z = pr({ number: 13, head: "z", base: "y" }); + +// A second independent 2-deep stack: P(base=main) ← Q(base=P) +const P = pr({ number: 20, head: "p", base: "main" }); +const Q = pr({ number: 21, head: "q", base: "p" }); + +interface Case { + name: string; + prs: PRListItem[]; + expected: { stackLabels: string[]; looseNumbers: number[] }; +} + +const cases: Case[] = [ + { + name: "3-deep stack, leaf-first order", + prs: [C, B, A], + expected: { stackLabels: ["#1 → #3"], looseNumbers: [] }, + }, + { + name: "3-deep stack, base-first order", + prs: [A, B, C], + expected: { stackLabels: ["#1 → #3"], looseNumbers: [] }, + }, + { + name: "3-deep stack, interleaved order", + prs: [B, A, C], + expected: { stackLabels: ["#1 → #3"], looseNumbers: [] }, + }, + { + name: "4-deep stack, base-first order", + prs: [W, X, Y, Z], + expected: { stackLabels: ["#10 → #13"], looseNumbers: [] }, + }, + { + name: "4-deep stack, scrambled order", + prs: [Y, W, Z, X], + expected: { stackLabels: ["#10 → #13"], looseNumbers: [] }, + }, + { + name: "two independent stacks in one input", + prs: [A, B, C, P, Q], + expected: { stackLabels: ["#1 → #3", "#20 → #21"], looseNumbers: [] }, + }, + { + name: "two independent stacks, interleaved input", + prs: [Q, B, A, P, C], + expected: { stackLabels: ["#1 → #3", "#20 → #21"], looseNumbers: [] }, + }, + { + name: "all loose — every PR based on default branch", + prs: [ + pr({ number: 30, head: "f1", base: "main" }), + pr({ number: 31, head: "f2", base: "main" }), + pr({ number: 32, head: "f3", base: "main" }), + ], + expected: { stackLabels: [], looseNumbers: [30, 31, 32] }, + }, + { + name: "single non-default-based PR with no parent in the set stays loose", + prs: [pr({ number: 40, head: "feature", base: "missing-parent" })], + expected: { stackLabels: [], looseNumbers: [40] }, + }, + { + name: "stack plus an unrelated loose PR", + prs: [A, B, C, pr({ number: 50, head: "solo", base: "main" })], + expected: { stackLabels: ["#1 → #3"], looseNumbers: [50] }, + }, + { + name: "empty input", + prs: [], + expected: { stackLabels: [], looseNumbers: [] }, + }, +]; + +describe("buildStacks", () => { + for (const c of cases) { + test(c.name, () => { + // Output is now fully order-independent: stacks sorted by base PR number, + // loose sorted by number. Assert the raw arrays — no sort() smoothing. + const { stacks, loose } = buildStacks(c.prs); + expect(stacks.map((s) => s.label)).toEqual(c.expected.stackLabels); + expect(loose.map((pr) => pr.number)).toEqual(c.expected.looseNumbers); + }); + } + + test("stacks are ordered base → leaf with #base → #leaf labels", () => { + const { stacks } = buildStacks([C, A, B]); + expect(stacks).toHaveLength(1); + expect(stacks[0].prs.map((p) => p.number)).toEqual([1, 2, 3]); + expect(stacks[0].label).toBe("#1 → #3"); + }); + + test("the base PR (base = default branch) is included in the stack", () => { + const { stacks, loose } = buildStacks([A, B, C]); + expect(loose).toHaveLength(0); + expect(stacks[0].prs.some((p) => p.number === 1)).toBe(true); + }); + + // The core invariant: grouping must not depend on the order the API returns + // PRs. Every permutation of the same stack must yield identical grouping. + test("every permutation of a 3-deep stack yields the same single stack", () => { + for (const perm of permutations([A, B, C])) { + const { stacks, loose } = buildStacks(perm); + expect(stacks.map((s) => s.label)).toEqual(["#1 → #3"]); + expect(stacks[0].prs.map((p) => p.number)).toEqual([1, 2, 3]); + expect(loose).toHaveLength(0); + } + }); + + test("every permutation of a 4-deep stack yields the same single stack", () => { + for (const perm of permutations([W, X, Y, Z])) { + const { stacks, loose } = buildStacks(perm); + expect(stacks.map((s) => s.label)).toEqual(["#10 → #13"]); + expect(stacks[0].prs.map((p) => p.number)).toEqual([10, 11, 12, 13]); + expect(loose).toHaveLength(0); + } + }); + + // Output ordering itself is now order-independent: independent stacks come + // back sorted by base PR number, so they never swap positions between polls. + test("every permutation of two independent stacks yields the same ordered grouping", () => { + for (const perm of permutations([A, B, C, P, Q])) { + const { stacks, loose } = buildStacks(perm); + expect(stacks.map((s) => s.label)).toEqual(["#1 → #3", "#20 → #21"]); + expect(loose).toHaveLength(0); + } + }); + + // Cycles must not loop forever. A ↔ B (each based on the other's head) are + // neither leaves, so they never root a chain and fall through to loose. + test("a 2-cycle does not loop and lands in loose", () => { + const c1 = pr({ number: 60, head: "cy1", base: "cy2" }); + const c2 = pr({ number: 61, head: "cy2", base: "cy1" }); + const { stacks, loose } = buildStacks([c1, c2]); + expect(stacks).toHaveLength(0); + expect(loose.map((p) => p.number)).toEqual([60, 61]); + }); + + // A leaf feeding into a cycle terminates via the `stacked` guard: the walk + // visits leaf → cy1 → cy2 → cy1(already stacked, stop). The resulting chain + // includes the cyclic members. This pins current bounded behaviour — the key + // guarantee is termination, not a particular policy on cyclic members. + test("a leaf feeding into a cycle terminates and forms a bounded stack", () => { + const cy1 = pr({ number: 65, head: "cyc1", base: "cyc2" }); + const cy2 = pr({ number: 66, head: "cyc2", base: "cyc1" }); + const leaf = pr({ number: 67, head: "tip", base: "cyc1" }); + for (const perm of permutations([cy1, cy2, leaf])) { + const { stacks, loose } = buildStacks(perm); + // Terminates (no hang) and produces exactly one bounded stack containing + // every member of the walk, in base → leaf order. + expect(stacks).toHaveLength(1); + expect(stacks[0].prs.map((p) => p.number)).toEqual([66, 65, 67]); + expect(stacks[0].label).toBe("#66 → #67"); + expect(loose).toHaveLength(0); + } + }); + + // A fork (one branch is the base of two PRs) must group deterministically, + // independent of input order. The shared ancestor joins the chain rooted at + // the lowest-numbered leaf; the other leaf falls through to loose. We assert + // this across ALL permutations rather than one hardcoded ordering. + test("a fork groups deterministically across every input order", () => { + const root = pr({ number: 70, head: "root", base: "main" }); + const child1 = pr({ number: 71, head: "child1", base: "root" }); + const child2 = pr({ number: 72, head: "child2", base: "root" }); + for (const perm of permutations([root, child1, child2])) { + const { stacks, loose } = buildStacks(perm); + // Exactly one stack: the lowest-numbered leaf (71) wins the root. + expect(stacks).toHaveLength(1); + expect(stacks[0].prs.map((p) => p.number)).toEqual([70, 71]); + expect(stacks[0].label).toBe("#70 → #71"); + // The shared ancestor is never double-counted. + const allStackNumbers = stacks.flatMap((s) => s.prs.map((p) => p.number)); + expect(allStackNumbers.filter((n) => n === 70)).toHaveLength(1); + // The losing sibling is loose. + expect(loose.map((p) => p.number)).toEqual([72]); + } + }); + + // Duplicate head branches (e.g. `state=all` returns a merged + an open PR on + // the same branch) must resolve deterministically when a chain follows that + // head: the open PR wins the `byHead` mapping so chain-following does not + // depend on input order. Here a tip (#91) is based on branch `mid`, which is + // the head of both a merged (#88) and an open (#89) PR. + test("duplicate head branches resolve to the open PR across every order", () => { + const tip = pr({ number: 91, head: "tip", base: "mid" }); + const midMerged = pr({ number: 88, head: "mid", base: "main", state: "merged" }); + const midOpen = pr({ number: 89, head: "mid", base: "main", state: "open" }); + for (const perm of permutations([tip, midMerged, midOpen])) { + const { stacks, loose } = buildStacks(perm); + // The open PR (#89) wins byHead, so the chain is #89 → #91 and the merged + // duplicate (#88) — never followed — is loose. + expect(stacks).toHaveLength(1); + expect(stacks[0].prs.map((p) => p.number)).toEqual([89, 91]); + expect(stacks[0].label).toBe("#89 → #91"); + expect(loose.map((p) => p.number)).toEqual([88]); + } + }); +}); diff --git a/apps/frontend/src/components/landing/buildStacks.ts b/apps/frontend/src/components/landing/buildStacks.ts new file mode 100644 index 000000000..15b110bf4 --- /dev/null +++ b/apps/frontend/src/components/landing/buildStacks.ts @@ -0,0 +1,107 @@ +import type { PRListItem } from "../../daemon/contracts"; + +export interface PRStack { + prs: PRListItem[]; + label: string; +} + +/** + * Group PRs into "stacks" — chains where each PR's base branch is the previous + * PR's head branch (rather than the repo default branch). + * + * Each chain is rooted at a *leaf* (a PR whose head branch is not any other + * PR's base branch) and walked downward toward its base, following + * `baseBranch → headBranch` edges. Rooting from leaves captures the full chain + * in a single pass, so the grouping is independent of the order PRs arrive in. + * + * Determinism is total — every output is independent of input array order: + * - Candidate leaves are processed in ascending PR `number` order. + * - `byHead` collisions (two PRs on the same head branch) resolve to the open + * PR, then to the lower PR `number`. + * - The returned `stacks` (by base PR `number`) and `loose` (by `number`) are + * sorted before returning. + * + * Forks (two PRs sharing a base) are handled deterministically rather than + * merged: the shared ancestor joins exactly one chain — the one rooted at the + * lowest-numbered leaf — and the sibling leaf(s) fall through to `loose`. This + * is a deliberate "one child wins, the rest are loose" policy, not full fork + * collapse, but the *choice* of which child wins is now order-independent. + * + * Output shape: + * - `stacks`: chains of length > 1, ordered base → leaf, labelled + * `# → #`, sorted by the base PR's `number`. + * - `loose`: every PR not part of a multi-PR stack, sorted by `number`. + * + * The base PR of a stack (whose base is the default branch) is included in the + * stack via the walk. Single non-default-based PRs with no parent in the set, + * and cycles, fall through to `loose`. + */ +export function buildStacks(prs: PRListItem[]): { + stacks: PRStack[]; + loose: PRListItem[]; +} { + // headBranch → PR, so we can follow a PR's baseBranch to its parent PR. When + // two PRs share a head branch (e.g. `state=all` returns a merged + an open PR + // on the same branch), keep a deterministic winner — prefer the open PR, then + // the lower PR number — so chain-following does not depend on input order. + const byHead = new Map(); + for (const pr of prs) { + const existing = byHead.get(pr.headBranch); + if (!existing || preferHead(pr, existing)) byHead.set(pr.headBranch, pr); + } + + // Every branch that some PR is based on. A PR is a leaf when its head branch + // is not in this set — i.e. nothing in the set is stacked on top of it. + const baseBranches = new Set(); + for (const pr of prs) baseBranches.add(pr.baseBranch); + + const stacked = new Set(); + const chains: PRListItem[][] = []; + + // Root each chain from a leaf and walk down toward its base. Leaves are + // visited in ascending PR-number order so that when two leaves share an + // ancestor (a fork), the lowest-numbered leaf deterministically claims it. + const leaves = prs + .filter((pr) => !baseBranches.has(pr.headBranch)) + .sort((a, b) => a.number - b.number); + + for (const leaf of leaves) { + if (stacked.has(leaf.id)) continue; + + const chain: PRListItem[] = []; + let current: PRListItem | undefined = leaf; + while (current && !stacked.has(current.id)) { + chain.unshift(current); + stacked.add(current.id); + current = byHead.get(current.baseBranch); + } + + if (chain.length > 1) { + chains.push(chain); + } else { + // A lone PR (no parent in the set, or whose ancestor was already claimed + // by a lower-numbered fork sibling) is not a stack — release it so it + // surfaces as loose, matching single-chain behaviour. + stacked.delete(chain[0].id); + } + } + + const stacks = chains + .map((chain) => ({ + prs: chain, + label: `#${chain[0].number} → #${chain[chain.length - 1].number}`, + })) + .sort((a, b) => a.prs[0].number - b.prs[0].number); + const loose = prs + .filter((pr) => !stacked.has(pr.id)) + .sort((a, b) => a.number - b.number); + return { stacks, loose }; +} + +/** True when `candidate` should win a head-branch collision over `existing`. */ +function preferHead(candidate: PRListItem, existing: PRListItem): boolean { + const candidateOpen = candidate.state === "open"; + const existingOpen = existing.state === "open"; + if (candidateOpen !== existingOpen) return candidateOpen; + return candidate.number < existing.number; +} diff --git a/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx new file mode 100644 index 000000000..2720756cc --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx @@ -0,0 +1,122 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { GitPullRequest, GitMerge, FileEdit } from "lucide-react"; +import { toast } from "sonner"; +import { daemonApiClient } from "../../../daemon/api/client"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { useGitDashboard } from "./use-git-dashboard"; +import { MetricCards } from "./MetricCards"; +import { PRGroup } from "./PRGroup"; +import { PRRow } from "./PRRow"; + +interface GitDashboardProps { + active: boolean; + onBack: () => void; +} + +export function GitDashboard({ active, onBack }: GitDashboardProps) { + const { groups, metrics, loading, error, isEmpty } = useGitDashboard(active); + const [launchingId, setLaunchingId] = useState(null); + const navigate = useNavigate(); + + const handleSelect = useCallback( + async (pr: GitDashboardPR) => { + setLaunchingId(pr.url); + const result = await daemonApiClient.createReviewSession(pr.projectCwd, pr.url); + setLaunchingId(null); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to start review", { description: result.error.message }); + } + }, + [navigate], + ); + + return ( +
+
+
+ +
+ + {loading && isEmpty && ( +
Loading PRs…
+ )} + + {!loading && isEmpty && ( +
+ {error ?? "No pull requests found across your projects"} +
+ )} + + {!isEmpty && ( +
+
+
+ {groups.open.length > 0 && ( + + {groups.open.map((pr) => ( + + ))} + + )} + {groups.draft.length > 0 && ( + + {groups.draft.map((pr) => ( + + ))} + + )} + {groups.merged.length > 0 && ( + + {groups.merged.map((pr) => ( + + ))} + + )} +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx new file mode 100644 index 000000000..5e605e529 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { DashboardMetrics } from "./use-git-dashboard"; + +interface MetricCardProps { + label: string; + count: number; + active?: boolean; + onClick?: () => void; +} + +function MetricCard({ label, count, active, onClick }: MetricCardProps) { + return ( + + ); +} + +function scrollToGroup(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +export function MetricCards({ metrics }: { metrics: DashboardMetrics }) { + return ( +
+

Pull Requests

+ scrollToGroup("pr-group-open")} + /> + scrollToGroup("pr-group-draft")} + /> + scrollToGroup("pr-group-merged")} + /> +
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx new file mode 100644 index 000000000..42fb6928a --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; + +interface PRGroupProps { + id?: string; + title: string; + icon: LucideIcon; + count: number; + children: React.ReactNode; +} + +export function PRGroup({ id, title, icon: Icon, count, children }: PRGroupProps) { + return ( +
+
+ + {title} + + {count} + +
+
{children}
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx new file mode 100644 index 000000000..c4287244e --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx @@ -0,0 +1,99 @@ +import { cn } from "@/lib/utils"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { formatRelativeTime } from "./use-git-dashboard"; + +const STATUS_COLORS: Record = { + open: "text-green-500", + merged: "text-purple-500", + closed: "text-red-500", + draft: "text-muted-foreground/50", +}; + +const REVIEW_BADGES: Record = { + APPROVED: { label: "Approved", className: "bg-green-500/10 text-green-600 dark:text-green-400" }, + CHANGES_REQUESTED: { + label: "Changes requested", + className: "bg-red-500/10 text-red-600 dark:text-red-400", + }, + REVIEW_REQUIRED: { + label: "Review required", + className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + }, +}; + +interface PRRowProps { + pr: GitDashboardPR; + loading?: boolean; + onSelect: (pr: GitDashboardPR) => void; +} + +export function PRRow({ pr, loading, onSelect }: PRRowProps) { + const statusKey = pr.isDraft && pr.state === "open" ? "draft" : pr.state; + const reviewBadge = pr.reviewDecision ? (REVIEW_BADGES[pr.reviewDecision] ?? null) : null; + const repoName = pr.repoSlug.split("/")[1] ?? pr.repoSlug; + + return ( + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts new file mode 100644 index 000000000..aec89ee78 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import { useProjectStore } from "../../../stores/project-store"; +import { useGitDashboardStore, type GitDashboardPR } from "../../../stores/git-dashboard-store"; + +export interface PRGroups { + open: GitDashboardPR[]; + draft: GitDashboardPR[]; + merged: GitDashboardPR[]; +} + +export interface DashboardMetrics { + open: number; + draft: number; + merged: number; + total: number; +} + +function groupPRs(prs: GitDashboardPR[]): PRGroups { + const open: GitDashboardPR[] = []; + const draft: GitDashboardPR[] = []; + const merged: GitDashboardPR[] = []; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft.push(pr); + else if (pr.state === "open") open.push(pr); + else if (pr.state === "merged") merged.push(pr); + } + return { open, draft, merged }; +} + +function computeMetrics(prs: GitDashboardPR[]): DashboardMetrics { + let open = 0; + let draft = 0; + let merged = 0; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft++; + else if (pr.state === "open") open++; + else if (pr.state === "merged") merged++; + } + return { open, draft, merged, total: prs.length }; +} + +export function formatRelativeTime(iso: string): string { + if (!iso) return ""; + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +const STALE_MS = 30_000; + +export function useGitDashboard(active = true) { + const projects = useProjectStore((s) => s.projects); + const prs = useGitDashboardStore((s) => s.prs); + const loading = useGitDashboardStore((s) => s.loading); + const error = useGitDashboardStore((s) => s.error); + const lastFetchedAt = useGitDashboardStore((s) => s.lastFetchedAt); + const lastProjectKey = useGitDashboardStore((s) => s.lastProjectKey); + const fetchAllPRs = useGitDashboardStore((s) => s.fetchAllPRs); + + const clear = useGitDashboardStore((s) => s.clear); + + useEffect(() => { + if (!active) return; + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) { + if (prs.length > 0) clear(); + return; + } + const projectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + const stale = + !lastFetchedAt || Date.now() - lastFetchedAt > STALE_MS || projectKey !== lastProjectKey; + if (stale && !loading) fetchAllPRs(projects); + }, [active, projects, prs.length, lastFetchedAt, lastProjectKey, loading, fetchAllPRs, clear]); + + const groups = useMemo(() => groupPRs(prs), [prs]); + const metrics = useMemo(() => computeMetrics(prs), [prs]); + + const isEmpty = + groups.open.length === 0 && groups.draft.length === 0 && groups.merged.length === 0; + + return { groups, metrics, loading, error, isEmpty }; +} diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx new file mode 100644 index 000000000..1aac30548 --- /dev/null +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; +import { ReviewAppEmbedded } from "@plannotator/code-review"; +import { PlanAppEmbedded } from "@plannotator/plan-review"; +import "@plannotator/code-review/styles"; +import "@plannotator/plan-review/styles"; +import type { SessionBootstrap } from "../../daemon/contracts"; +import { appStore } from "../../stores/app-store"; + +const sidebarTrigger = ( + +); + +const openSettings = () => appStore.getState().setSettingsOpen(true); + +interface SessionSurfaceProps { + bootstrap: SessionBootstrap; +} + +export const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { + const { session } = bootstrap; + + if (session.mode === "review") { + return ( + + + + ); + } + + return ( + + + + ); +}); diff --git a/apps/frontend/src/components/settings/AppSettingsDialog.tsx b/apps/frontend/src/components/settings/AppSettingsDialog.tsx new file mode 100644 index 000000000..c809a2551 --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,331 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useAppStore } from "../../stores/app-store"; +import { GeneralTab } from "@plannotator/ui/components/settings/GeneralTab"; +import { PlanGeneralTab } from "@plannotator/ui/components/settings/PlanGeneralTab"; +import { PlanDisplayTab } from "@plannotator/ui/components/settings/PlanDisplayTab"; +import { SavingTab } from "@plannotator/ui/components/settings/SavingTab"; +import { LabelsTab } from "@plannotator/ui/components/settings/LabelsTab"; +import { FilesTab } from "@plannotator/ui/components/settings/FilesTab"; +import { GitTab, ReviewDisplayTab, CommentsTab } from "@plannotator/ui/components/Settings"; +import { ThemeTab } from "@plannotator/ui/components/ThemeTab"; +import { KeyboardShortcuts } from "@plannotator/ui/components/KeyboardShortcuts"; +import { AISettingsTab } from "@plannotator/ui/components/AISettingsTab"; +import { HooksTab } from "@plannotator/ui/components/settings/HooksTab"; +import { getAIProviderSettings, saveAIProviderSettings } from "@plannotator/ui/utils/aiProvider"; +import { configStore } from "@plannotator/ui/config"; + +interface TabDef { + id: string; + label: string; +} + +const GENERAL_TABS: TabDef[] = [ + { id: "general", label: "General" }, + { id: "theme", label: "Theme" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +const PLAN_TABS: TabDef[] = [ + { id: "plan-general", label: "General" }, + { id: "plan-display", label: "Display" }, + { id: "plan-saving", label: "Saving" }, + { id: "plan-labels", label: "Labels" }, + { id: "plan-hooks", label: "Hooks" }, +]; + +const REVIEW_TABS: TabDef[] = [ + { id: "review-git", label: "Git" }, + { id: "review-display", label: "Display" }, + { id: "review-comments", label: "Comments" }, + { id: "review-ai", label: "AI" }, +]; + + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const [activeTab, setActiveTab] = useState("general"); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setThemePreview(false); + setOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [themePreview, setOpen]); + + // Force re-mount of tab content when dialog opens to ensure fresh state + const [mountKey, setMountKey] = useState(0); + useEffect(() => { + if (open) setMountKey((k) => k + 1); + }, [open]); + + // Detect origin from the active session (if any) + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const activeOrigin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + + // Fetch git user and config from daemon on open + const [gitUser, setGitUser] = useState(); + const [legacyTabMode, setLegacyTabMode] = useState(false); + + useEffect(() => { + if (!open) return; + fetch("/daemon/git/user") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.gitUser) setGitUser(data.gitUser); + }) + .catch(() => {}); + fetch("/daemon/config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.config) { + configStore.getState().init(data.config); + setLegacyTabMode(!!data.config.legacyTabMode); + } + }) + .catch(() => {}); + }, [open]); + + // Daemon-routed fetch for tabs that need server calls without session context + const daemonFetch = useCallback((input: string, init?: RequestInit) => { + const path = + typeof input === "string" && input.startsWith("/api/") ? `/daemon${input.slice(4)}` : input; + return fetch(path, init); + }, []); + + // AI provider state — fetched once when dialog opens + const [aiProviders, setAiProviders] = useState< + Array<{ id: string; name: string; capabilities: Record }> + >([]); + const [aiProviderId, setAiProviderId] = useState( + () => getAIProviderSettings().providerId, + ); + + // Re-read AI provider on each open (could have changed via per-surface settings) + useEffect(() => { + if (open) setAiProviderId(getAIProviderSettings().providerId); + }, [open]); + + useEffect(() => { + if (!open) return; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + if (!apiBase) return; + fetch(`${apiBase}/ai/capabilities`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.providers) setAiProviders(data.providers); + }) + .catch(() => {}); + }, [open, activeSessionId]); + + const handleAiProviderChange = useCallback((providerId: string | null) => { + setAiProviderId(providerId); + const current = getAIProviderSettings(); + saveAIProviderSettings({ ...current, providerId }); + }, []); + + return ( + <> + + + Settings + +
+
+ Settings +
+ v{__APP_VERSION__} + · + + Send feedback + +
+
+ +
+ + General + {GENERAL_TABS.map((tab) => ( + + {tab.label} + + ))} + + Plan Review + {PLAN_TABS.map((tab) => ( + + {tab.label} + + ))} + + Code Review + {REVIEW_TABS.map((tab) => ( + + {tab.label} + + ))} + + + Files + + +
+
+ +
+
+ +
+
+ {/* General */} + + { + setLegacyTabMode(enabled); + fetch("/daemon/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ legacyTabMode: enabled }), + }).catch(() => {}); + }} + /> + + + { + setOpen(false); + setThemePreview(true); + }} + /> + + +
+
+
+ Plan Review +
+ +
+
+
+ Code Review +
+ +
+
+
+ + {/* Plan Review */} + + + + + + + + + + + + + + + + + {/* Code Review */} + + + + + + + + + + + + + + + + +
+
+
+
+
+ + {themePreview && + createPortal( +
+
+
+
+ + Theme Preview + + +
+
+ +
+
+
, + document.body, + )} + + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx new file mode 100644 index 000000000..54c34b9ad --- /dev/null +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Moon, Settings, Sun } from "lucide-react"; +import { TaterSpriteSidebar } from "./TaterSpriteSidebar"; +import { appStore } from "../../stores/app-store"; +import { cn } from "@/lib/utils"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { useTheme } from "@plannotator/ui/components/ThemeProvider"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import type { SessionSummary } from "../../daemon/contracts"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; + +const MODE_ORDER = ["plan", "review", "annotate", "goal-setup"]; + +export function AppSidebarContent() { + const sessions = useDaemonEventStore((s) => s.sessions); + const { resolvedMode, setMode } = useTheme(); + const matchRoute = useMatchRoute(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + const list = map.get(s.mode) ?? []; + list.push(s); + map.set(s.mode, list); + } + return map; + }, [sessions]); + + const toggleTheme = useCallback(() => { + setMode(resolvedMode === "dark" ? "light" : "dark"); + }, [resolvedMode, setMode]); + + return ( + <> + + + +
+ + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + +
+ +
+ + + {MODE_ORDER.map((mode) => { + const modeSessions = grouped.get(mode); + if (!modeSessions?.length) return null; + const meta = getSessionModeMeta(mode); + + const Icon = meta.icon; + return ( + + + + {meta.label}s + + + + {modeSessions.map((session) => { + const isActive = !!matchRoute({ + to: "/s/$sessionId", + params: { sessionId: session.id }, + }); + const isTerminal = + session.status === "completed" || session.status === "cancelled"; + + return ( + + + + + + {formatSessionLabel(session.label, session.mode)} + + + + + ); + })} + + + + ); + })} + + + + + + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings + + + + + {resolvedMode === "dark" ? : } + Toggle theme + + + + + + ); +} + +export function AppSidebar() { + return ( + + + + ); +} diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..705d25dc5 --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const hideTimeout = useRef | null>(null); + + const show = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + hideTimeout.current = setTimeout(() => setVisible(false), 150); + }, []); + + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on left edge */} +
+ {/* Backdrop overlay */} + {backdropMounted && ( +
+ )} + {/* Floating sidebar panel */} +
+
+ +
+
+ + ); +} diff --git a/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx new file mode 100644 index 000000000..2da10b85c --- /dev/null +++ b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx @@ -0,0 +1,32 @@ +import spriteSheet from "../../assets/sprite_package_sidebar/sprite.png"; + +const NATIVE_W = 117; +const NATIVE_H = 96; +const FRAMES = 24; +const DISPLAY_H = 40; +const SCALE = DISPLAY_H / NATIVE_H; +const DISPLAY_W = NATIVE_W * SCALE; +const TOTAL_WIDTH = NATIVE_W * FRAMES * SCALE; + +export function TaterSpriteSidebar() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..acf8c3d9e --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,77 @@ +import { Slot, Slottable } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[color,background-color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + xxs: "h-6 rounded-md gap-1.5 px-2.5", + xs: "h-7 rounded-md gap-1.5 px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonIcon = React.ReactNode; + +function Button({ + children, + className, + variant, + size, + asChild = false, + iconLeft, + iconRight, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + iconLeft?: ButtonIcon; + iconRight?: ButtonIcon; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + {iconLeft ? ( + + ) : null} + {children} + {iconRight ? ( + + ) : null} + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1a98f2f22 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 000000000..13fc29e98 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx new file mode 100644 index 000000000..856296e91 --- /dev/null +++ b/apps/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx new file mode 100644 index 000000000..4873123f8 --- /dev/null +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { X } from "lucide-react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..21ea19364 --- /dev/null +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { PanelLeft } from "lucide-react"; +import { Slot } from "@radix-ui/react-slot"; +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +const SIDEBAR_STORAGE_KEY = "sidebar_state"; +const SIDEBAR_WIDTH = "244px"; // 16rem +const SIDEBAR_WIDTH_MOBILE = "260px"; // 18rem +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +const MOBILE_BREAKPOINT = 1024; + +function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(() => { + if (typeof window === "undefined") { + return defaultOpen; + } + + const storedOpenState = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); + return storedOpenState === null ? defaultOpen : storedOpenState === "true"; + }); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState)); + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + +
+ ))} +
+ )} + +
+ +
+ setNewDirPath(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addDirectory(); }} + placeholder="/path/to/directory" + className="flex-1 px-3 py-2 bg-muted rounded-lg text-sm font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + +
+
+ Add directories outside your project that contain markdown files. +
+
+ + )} +
+ ); +}; diff --git a/packages/ui/components/settings/GeneralTab.tsx b/packages/ui/components/settings/GeneralTab.tsx new file mode 100644 index 000000000..bb7fb7415 --- /dev/null +++ b/packages/ui/components/settings/GeneralTab.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { getIdentity, regenerateIdentity, setCustomIdentity } from '../../utils/identity'; +import { getAutoCloseDelay, setAutoCloseDelay, AUTO_CLOSE_OPTIONS, type AutoCloseDelay } from '../../utils/storage'; +import { GitUser } from '../../icons/GitUser'; +import { ToggleSwitch } from './shared'; + +interface GeneralTabProps { + gitUser?: string; + onIdentityChange?: (oldIdentity: string, newIdentity: string) => void; + legacyTabMode?: boolean; + onLegacyTabModeChange?: (enabled: boolean) => void; +} + +export const GeneralTab: React.FC = ({ gitUser, onIdentityChange, legacyTabMode, onLegacyTabModeChange }) => { + const [identity, setIdentity] = useState(() => getIdentity()); + const [autoClose, setAutoClose] = useState(() => getAutoCloseDelay()); + + const handleIdentitySave = (value: string) => { + const trimmed = value.trim(); + if (!trimmed || trimmed === identity) return; + const oldIdentity = identity; + setCustomIdentity(trimmed); + setIdentity(trimmed); + onIdentityChange?.(oldIdentity, trimmed); + }; + + const handleRegenerateIdentity = () => { + const oldIdentity = identity; + const newIdentity = regenerateIdentity(); + setIdentity(newIdentity); + onIdentityChange?.(oldIdentity, newIdentity); + }; + + const handleUseGitName = () => { + if (gitUser) handleIdentitySave(gitUser); + }; + + return ( +
+
+
Your Identity
+
+ Used when sharing annotations with others +
+
+ handleIdentitySave(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleIdentitySave((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).blur(); + } + }} + className="flex-1 px-3 py-2 bg-muted rounded-lg text-sm font-mono truncate border border-transparent focus:border-primary/50 focus:outline-none" + placeholder="Enter your name…" + /> + {gitUser && ( + + )} + +
+
+ +
+ +
+
Auto-close Tab
+ +
+ {AUTO_CLOSE_OPTIONS.find(o => o.value === autoClose)?.description} +
+
+ + {onLegacyTabModeChange && ( + <> +
+ + + )} +
+ ); +}; diff --git a/packages/ui/components/settings/HooksTab.tsx b/packages/ui/components/settings/HooksTab.tsx index 683ba3a59..a56245138 100644 --- a/packages/ui/components/settings/HooksTab.tsx +++ b/packages/ui/components/settings/HooksTab.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { FAVICON_SVG } from '@plannotator/shared/favicon'; +import { useSessionFetch } from '../../hooks/useSessionFetch'; interface HooksStatus { pfmReminder: { enabled: boolean }; @@ -48,7 +49,13 @@ const CopyPathButton: React.FC<{ filePath: string }> = ({ filePath }) => { ); }; -export const HooksTab: React.FC = () => { +interface HooksTabProps { + fetchFn?: (input: string, init?: RequestInit) => Promise; +} + +export const HooksTab: React.FC = ({ fetchFn }) => { + const sessionFetch = useSessionFetch(); + const fetch = fetchFn ?? sessionFetch; const [status, setStatus] = useState(null); const [pfmEnabled, setPfmEnabled] = useState(false); const [hookExpanded, setHookExpanded] = useState(false); diff --git a/packages/ui/components/settings/LabelsTab.tsx b/packages/ui/components/settings/LabelsTab.tsx new file mode 100644 index 000000000..83c9a8514 --- /dev/null +++ b/packages/ui/components/settings/LabelsTab.tsx @@ -0,0 +1,188 @@ +import React, { useState } from 'react'; +import { + type QuickLabel, + getQuickLabels, + saveQuickLabels, + resetQuickLabels, + DEFAULT_QUICK_LABELS, + getLabelColors, + LABEL_COLOR_MAP, +} from '../../utils/quickLabels'; +import { isMac, altKey } from '../../utils/platform'; + +export const LabelsTab: React.FC = () => { + const [labels, setLabels] = useState(() => getQuickLabels()); + const [editingTipIndex, setEditingTipIndex] = useState(null); + const [editingTipValue, setEditingTipValue] = useState(''); + + const updateLabels = (updated: QuickLabel[]) => { + setLabels(updated); + saveQuickLabels(updated); + }; + + return ( +
+
+
+
Quick Labels
+
+ Preset annotations for one-click feedback +
+
+ +
+ + + +
+ {labels.map((label, index) => { + const colors = getLabelColors(label.color); + const hasTip = !!label.tip; + const isEditingTip = editingTipIndex === index; + return ( +
+
+ {label.emoji} + { + const updated = [...labels]; + updated[index] = { + ...label, + text: e.target.value, + id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), + }; + updateLabels(updated); + }} + className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + + + {index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''} + + +
+ {isEditingTip && ( +
+ + + + setEditingTipValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const updated = [...labels]; + updated[index] = { ...label, tip: editingTipValue || undefined }; + updateLabels(updated); + setEditingTipIndex(null); + } + if (e.key === 'Escape') setEditingTipIndex(null); + }} + placeholder="AI instruction tip…" + className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50" + autoFocus + onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }} + /> + +
+ )} +
+ ); + })} +
+ + {labels.length < 12 && ( + + )} + +
+ Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 to apply a label instantly. +
+
+ ); +}; diff --git a/packages/ui/components/settings/PlanDisplayTab.tsx b/packages/ui/components/settings/PlanDisplayTab.tsx new file mode 100644 index 000000000..354a5ddf3 --- /dev/null +++ b/packages/ui/components/settings/PlanDisplayTab.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { + getUIPreferences, + saveUIPreferences, + PLAN_WIDTH_OPTIONS, + type UIPreferences, + type PlanWidth, +} from '../../utils/uiPreferences'; +import { configStore, useConfigValue } from '../../config'; +import { ToggleSwitch } from './shared'; + +interface PlanDisplayTabProps { + onUIPreferencesChange?: (prefs: UIPreferences) => void; +} + +export const PlanDisplayTab: React.FC = ({ onUIPreferencesChange }) => { + const taterMode = useConfigValue('taterMode'); + const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); + + const handleChange = (updates: Partial) => { + const next = { ...uiPrefs, ...updates }; + setUiPrefs(next); + saveUIPreferences(next); + onUIPreferencesChange?.(next); + }; + + const active = PLAN_WIDTH_OPTIONS.find((o) => o.id === uiPrefs.planWidth) ?? PLAN_WIDTH_OPTIONS[0]; + const cardPctMap: Record = { compact: 48, default: 70, wide: 94 }; + + return ( +
+ handleChange({ tocEnabled: v })} + label="Auto-open Sidebar" + description="Open sidebar with Table of Contents on load" + /> + +
+ + handleChange({ stickyActionsEnabled: v })} + label="Sticky Actions" + description="Keep action buttons visible while scrolling" + /> + +
+ +
+
+
Plan Width
+
Maximum width of the plan card
+
+
+ {PLAN_WIDTH_OPTIONS.map((opt) => ( + + ))} +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {active.px}px — {active.hint} +
+
+ +
+ configStore.getState().set('taterMode', v)} + label="Tater Mode" + /> +
+ ); +}; diff --git a/packages/ui/components/settings/PlanGeneralTab.tsx b/packages/ui/components/settings/PlanGeneralTab.tsx new file mode 100644 index 000000000..570d787b5 --- /dev/null +++ b/packages/ui/components/settings/PlanGeneralTab.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import type { Origin } from '@plannotator/shared/agents'; +import { + getPermissionModeSettings, + savePermissionModeSettings, + PERMISSION_MODE_OPTIONS, + type PermissionMode, +} from '../../utils/permissionMode'; +import { + getAgentSwitchSettings, + saveAgentSwitchSettings, + AGENT_OPTIONS, +} from '../../utils/agentSwitch'; +import { useAgents } from '../../hooks/useAgents'; + +interface PlanGeneralTabProps { + origin?: Origin | string | null; +} + +export const PlanGeneralTab: React.FC = ({ origin }) => { + const [permissionMode, setPermissionMode] = useState( + () => getPermissionModeSettings().mode, + ); + const [agent, setAgent] = useState(() => getAgentSwitchSettings()); + const [agentWarning, setAgentWarning] = useState(null); + const { agents: availableAgents } = useAgents((origin as Origin) ?? null); + + const handlePermissionModeChange = (mode: PermissionMode) => { + setPermissionMode(mode); + savePermissionModeSettings(mode); + }; + + const handleAgentChange = (switchTo: string, customName?: string) => { + const next = { switchTo, customName: customName ?? agent.customName }; + setAgent(next); + saveAgentSwitchSettings(next); + }; + + const validateAgent = (name: string) => + availableAgents.some((a) => a.id.toLowerCase() === name.toLowerCase()); + + const showPermission = origin === 'claude-code'; + const showAgent = origin === 'opencode'; + + if (!showPermission && !showAgent) { + return ( +
+ No plan-specific settings available for this agent. +
+ ); + } + + return ( +
+ {showPermission && ( +
+
Permission Mode
+
+ Automation level after plan approval +
+ +
+ {PERMISSION_MODE_OPTIONS.find((o) => o.value === permissionMode)?.description} +
+
+ )} + + {showAgent && ( +
+
Agent Switching
+
+ Which agent to switch to after plan approval +
+ {agentWarning && ( +
+ + + + {agentWarning} +
+ )} + + {agent.switchTo === 'custom' && ( + { + const customName = e.target.value; + handleAgentChange('custom', customName); + if (customName && availableAgents.length > 0) { + setAgentWarning( + validateAgent(customName) ? null : `Agent "${customName}" not found in OpenCode.`, + ); + } else { + setAgentWarning(null); + } + }} + placeholder="Enter agent name…" + className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + )} +
+ {agent.switchTo === 'custom' && agent.customName + ? `Switch to "${agent.customName}" agent after approval` + : agent.switchTo === 'disabled' + ? 'Stay on current agent after approval' + : `Switch to ${agent.switchTo} agent after approval`} +
+
+ )} +
+ ); +}; diff --git a/packages/ui/components/settings/SavingTab.tsx b/packages/ui/components/settings/SavingTab.tsx new file mode 100644 index 000000000..e6df8d317 --- /dev/null +++ b/packages/ui/components/settings/SavingTab.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { + getPlanSaveSettings, + savePlanSaveSettings, + type PlanSaveSettings, +} from '../../utils/planSave'; +import { ToggleSwitch } from './shared'; + +export const SavingTab: React.FC = () => { + const [planSave, setPlanSave] = useState(() => getPlanSaveSettings()); + + const handlePlanSaveChange = (updates: Partial) => { + const next = { ...planSave, ...updates }; + setPlanSave(next); + savePlanSaveSettings(next); + }; + + return ( +
+ handlePlanSaveChange({ enabled: v })} + label="Save Plans" + description="Auto-save plans to ~/.plannotator/plans/" + /> + + {planSave.enabled && ( +
+ + handlePlanSaveChange({ customPath: e.target.value || null })} + placeholder="~/.plannotator/plans/" + className="w-full px-3 py-2 bg-muted rounded-lg text-sm font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+ Leave empty to use default location +
+
+ )} +
+ ); +}; diff --git a/packages/ui/components/settings/shared.tsx b/packages/ui/components/settings/shared.tsx new file mode 100644 index 000000000..18a77dced --- /dev/null +++ b/packages/ui/components/settings/shared.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +export function SegmentedControl({ options, value, onChange }: { + options: { value: T; label: string }[]; + value: T; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +export function ToggleSwitch({ checked, onChange, label, description }: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; + description?: string; +}) { + return ( +
+
+
{label}
+ {description &&
{description}
} +
+ +
+ ); +} diff --git a/packages/ui/components/sidebar/ArchiveBrowser.tsx b/packages/ui/components/sidebar/ArchiveBrowser.tsx deleted file mode 100644 index 4505895a6..000000000 --- a/packages/ui/components/sidebar/ArchiveBrowser.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * ArchiveBrowser — Browsable list of saved plan decisions - * - * Reusable in both standalone archive mode and as a sidebar tab - * during normal plan review sessions. - */ - -import React from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; - -export type { ArchivedPlan }; - -interface ArchiveBrowserProps { - plans: ArchivedPlan[]; - selectedFile: string | null; - onSelect: (filename: string) => void; - isLoading: boolean; -} - -function relativeTime(dateStr: string): string { - if (!dateStr) return ""; - const now = Date.now(); - const then = new Date(dateStr + "T12:00:00").getTime(); - const diff = now - then; - - if (diff < 86_400_000) return "today"; - if (diff < 172_800_000) return "yesterday"; - if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; - if (diff < 2_592_000_000) return `${Math.floor(diff / 604_800_000)}w ago`; - return dateStr; -} - -export const ArchiveBrowser: React.FC = ({ - plans, - selectedFile, - onSelect, - isLoading, -}) => { - if (isLoading) { - return ( -
- Loading archive... -
- ); - } - - if (plans.length === 0) { - return ( -
- No archived plans found. -
- ); - } - - return ( -
-
- {plans.map((plan, i) => { - const showDateHeader = i === 0 || plan.date !== plans[i - 1].date; - - const isSelected = selectedFile === plan.filename; - - return ( - - {showDateHeader && ( -
- {relativeTime(plan.date)} — {plan.date} -
- )} - -
- ); - })} -
-
- ); -}; diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 734fcb904..e08bee345 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -9,7 +9,6 @@ import React from "react"; import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; -import { ObsidianIconRaw } from "../icons/ObsidianIcons"; interface FileBrowserProps { dirs: DirState[]; @@ -20,7 +19,6 @@ interface FileBrowserProps { onSelectFile: (absolutePath: string, dirPath: string) => void; activeFile: string | null; onFetchAll: () => void; - onRetryVaultDir?: (vaultPath: string) => void; annotationCounts?: Map; highlightedFiles?: Set; } @@ -192,7 +190,6 @@ export const FileBrowser: React.FC = ({ onSelectFile, activeFile, onFetchAll, - onRetryVaultDir, annotationCounts, highlightedFiles, }) => { @@ -233,7 +230,6 @@ export const FileBrowser: React.FC = ({ > - {dir.isVault && }
{dir.name}
@@ -245,7 +241,7 @@ export const FileBrowser: React.FC = ({ onToggleFolder={onToggleFolder} onSelectFile={onSelectFile} activeFile={activeFile} - onRetry={dir.isVault && onRetryVaultDir ? () => onRetryVaultDir(dir.path) : onFetchAll} + onRetry={onFetchAll} annotationCounts={annotationCounts} highlightedFiles={highlightedFiles} /> diff --git a/packages/ui/components/sidebar/SidebarContainer.tsx b/packages/ui/components/sidebar/SidebarContainer.tsx index 5d82b0dc8..5a0d0e5fd 100644 --- a/packages/ui/components/sidebar/SidebarContainer.tsx +++ b/packages/ui/components/sidebar/SidebarContainer.tsx @@ -1,7 +1,7 @@ /** * SidebarContainer — Shared sidebar shell * - * Houses the Table of Contents, Version Browser, File Browser, and Archive Browser views. + * Houses the Table of Contents, Version Browser, and File Browser views. * Tab bar at top switches between them. */ @@ -13,7 +13,6 @@ import type { UseFileBrowserReturn } from "../../hooks/useFileBrowser"; import { TableOfContents } from "../TableOfContents"; import { VersionBrowser } from "./VersionBrowser"; import { FileBrowser } from "./FileBrowser"; -import { ArchiveBrowser, type ArchivedPlan } from "./ArchiveBrowser"; import { OverlayScrollArea } from "../OverlayScrollArea"; interface SidebarContainerProps { @@ -36,7 +35,6 @@ interface SidebarContainerProps { fileBrowser?: UseFileBrowserReturn; onFilesSelectFile?: (absolutePath: string, dirPath: string) => void; onFilesFetchAll?: () => void; - onFilesRetryVaultDir?: (vaultPath: string) => void; // Version Browser props showVersionsTab?: boolean; versionInfo: VersionInfo | null; @@ -52,12 +50,6 @@ interface SidebarContainerProps { onFetchVersions: () => void; // Annotation indicators hasFileAnnotations?: boolean; - // Archive Browser props - showArchiveTab?: boolean; - archivePlans: ArchivedPlan[]; - selectedArchiveFile: string | null; - onArchiveSelect: (filename: string) => void; - isLoadingArchive: boolean; } export const SidebarContainer: React.FC = ({ @@ -78,7 +70,6 @@ export const SidebarContainer: React.FC = ({ fileBrowser, onFilesSelectFile, onFilesFetchAll, - onFilesRetryVaultDir, showVersionsTab, versionInfo, versions, @@ -92,11 +83,6 @@ export const SidebarContainer: React.FC = ({ fetchingVersion, onFetchVersions, hasFileAnnotations, - showArchiveTab, - archivePlans, - selectedArchiveFile, - onArchiveSelect, - isLoadingArchive, }) => { return ( ); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 382afb65e..739e7c233 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -1,21 +1,23 @@ /** - * ConfigStore — Unified config resolver for Plannotator + * ConfigStore — Zustand-based config for Plannotator * - * Singleton that resolves settings with precedence: - * server config file > cookie > default + * Resolves settings with precedence: server config > cookie > default. + * Selector-based subscriptions: components only re-render when their + * specific setting changes (unlike the old broadcast-to-all pattern). * - * Works both inside and outside React. React components subscribe - * via useSyncExternalStore (see useConfig.ts). - * - * Server-synced settings automatically write back to ~/.plannotator/config.json + * Server-synced settings write back to ~/.plannotator/config.json * via a debounced POST /api/config. */ +import { createStore, useStore } from 'zustand'; import { SETTINGS, type SettingName, type SettingsMap } from './settings'; +import { apiFetch } from '../utils/api'; -type Listener = () => void; +/** Infer the value type from a SettingDef */ +export type SettingValue = SettingsMap[K] extends { defaultValue: infer D } + ? D extends (...args: unknown[]) => infer R ? R : D + : never; -/** Deep-merge source into target, recursing into plain objects. */ function deepMerge(target: Record, source: Record): void { for (const key of Object.keys(source)) { if ( @@ -29,100 +31,83 @@ function deepMerge(target: Record, source: Record = SettingsMap[K] extends { defaultValue: infer D } - ? D extends (...args: unknown[]) => infer R ? R : D - : never; +type ConfigState = { + [K in SettingName]: SettingValue; +} & { + get: (key: K) => SettingValue; + set: (key: K, value: SettingValue) => void; + init: (serverConfig?: Record) => void; +}; -class ConfigStore { - private values = new Map(); - private listeners = new Set(); - private version = 0; - private pendingServerWrites: Record = {}; - private serverSyncTimer: ReturnType | null = null; +let pendingServerWrites: Record = {}; +let serverSyncTimer: ReturnType | null = null; - constructor() { - // Eagerly resolve all settings from synchronous sources (cookie > default). - // The store is safe to read from the moment it's created. - for (const [name, def] of Object.entries(SETTINGS)) { - const fromCookie = def.fromCookie(); - const defaultVal = typeof def.defaultValue === 'function' - ? (def.defaultValue as () => unknown)() - : def.defaultValue; - const resolved = fromCookie ?? defaultVal; - this.values.set(name, resolved); - // Persist generated defaults to cookie so the value is stable across calls - if (fromCookie === undefined) { - def.toCookie(resolved as never); - } - } - } +function scheduleServerSync(): void { + if (serverSyncTimer) clearTimeout(serverSyncTimer); + serverSyncTimer = setTimeout(() => { + const payload = { ...pendingServerWrites }; + pendingServerWrites = {}; + apiFetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); + }, 300); +} - /** - * Apply server config overrides. - * Call once after fetching /api/plan or /api/diff. - * - * Server values take precedence over the cookie/default already resolved - * by the constructor. Settings without a server value are left untouched. - */ - init(serverConfig?: Record): void { - if (serverConfig) { - for (const [name, def] of Object.entries(SETTINGS)) { - if (def.serverKey && def.fromServer) { - const fromServer = def.fromServer(serverConfig); - if (fromServer !== undefined) { - this.values.set(name, fromServer); - def.toCookie(fromServer as never); - } - } - } +function resolveInitialValues(): Record { + const values: Record = {}; + for (const [name, def] of Object.entries(SETTINGS)) { + const fromCookie = def.fromCookie(); + const defaultVal = typeof def.defaultValue === 'function' + ? (def.defaultValue as () => unknown)() + : def.defaultValue; + const resolved = fromCookie ?? defaultVal; + values[name] = resolved; + if (fromCookie === undefined) { + def.toCookie(resolved as never); } - this.notify(); } + return values; +} - /** Get a resolved config value. Works outside React. */ - get(key: K): SettingValue { - return this.values.get(key) as SettingValue; - } +export const configStore = createStore()((setState, getState) => ({ + ...resolveInitialValues() as { [K in SettingName]: SettingValue }, - /** Set a config value. Writes cookie (sync), queues server write-back if applicable. */ - set(key: K, value: SettingValue): void { + get: (key: K): SettingValue => { + return getState()[key] as SettingValue; + }, + + set: (key: K, value: SettingValue): void => { const def = SETTINGS[key]; - this.values.set(key, value); def.toCookie(value as never); if (def.serverKey && def.toServer) { - deepMerge(this.pendingServerWrites, def.toServer(value as never) as Record); - this.scheduleServerSync(); + deepMerge(pendingServerWrites, def.toServer(value as never) as Record); + scheduleServerSync(); } - this.notify(); - } + setState({ [key]: value } as Partial); + }, - /** Subscribe to changes. Returns unsubscribe function. */ - subscribe(listener: Listener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - this.version++; - for (const fn of this.listeners) fn(); - } + init: (serverConfig?: Record): void => { + if (!serverConfig) return; + const updates: Record = {}; + for (const [name, def] of Object.entries(SETTINGS)) { + if (def.serverKey && def.fromServer) { + const fromServer = def.fromServer(serverConfig); + if (fromServer !== undefined) { + updates[name] = fromServer; + def.toCookie(fromServer as never); + } + } + } + if (Object.keys(updates).length > 0) { + setState(updates as Partial); + } + }, +})); - private scheduleServerSync(): void { - if (this.serverSyncTimer) clearTimeout(this.serverSyncTimer); - this.serverSyncTimer = setTimeout(() => { - const payload = { ...this.pendingServerWrites }; - this.pendingServerWrites = {}; - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => {}); // best-effort - }, 300); - } +export function useConfigStore(selector: (state: ConfigState) => T): T { + return useStore(configStore, selector); } - -export const configStore = new ConfigStore(); -export type { SettingValue }; diff --git a/packages/ui/config/index.ts b/packages/ui/config/index.ts index b511ee831..3a418e05c 100644 --- a/packages/ui/config/index.ts +++ b/packages/ui/config/index.ts @@ -1,2 +1,2 @@ -export { configStore } from './configStore'; +export { configStore, useConfigStore, type SettingValue } from './configStore'; export { useConfigValue } from './useConfig'; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 01f6d0cac..aeac54657 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -255,7 +255,18 @@ export const SETTINGS = { } }, }, -} satisfies Record>; + taterMode: { + defaultValue: false as boolean, + fromCookie: () => { + const v = storage.getItem('plannotator-tater-mode'); + return v === 'true' ? true : v === 'false' ? false : undefined; + }, + toCookie: (v: boolean) => storage.setItem('plannotator-tater-mode', String(v)), + serverKey: undefined, + fromServer: undefined, + toServer: undefined, + }, +} satisfies Record>; export type SettingsMap = typeof SETTINGS; export type SettingName = keyof SettingsMap; diff --git a/packages/ui/config/useConfig.ts b/packages/ui/config/useConfig.ts index 1bcb99a25..dc964b18a 100644 --- a/packages/ui/config/useConfig.ts +++ b/packages/ui/config/useConfig.ts @@ -1,20 +1,14 @@ /** * React hook for consuming ConfigStore values. * - * Uses useSyncExternalStore for concurrent-mode-safe subscriptions - * to the singleton configStore — no context provider needed. + * Uses Zustand selector-based subscriptions — components only re-render + * when their specific setting changes. */ -import { useCallback, useSyncExternalStore } from 'react'; -import { configStore, type SettingValue } from './configStore'; +import { useConfigStore, type SettingValue } from './configStore'; import type { SettingName } from './settings'; -/** Read a config value reactively. Re-renders when the store changes. */ +/** Read a config value reactively. Re-renders only when this key changes. */ export function useConfigValue(key: K): SettingValue { - const subscribe = useCallback( - (onStoreChange: () => void) => configStore.subscribe(onStoreChange), - [], - ); - const getSnapshot = useCallback(() => configStore.get(key), [key]); - return useSyncExternalStore(subscribe, getSnapshot); + return useConfigStore((s) => s[key]) as SettingValue; } diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 9bceb95c3..ec8ba9be7 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { AIContext } from '@plannotator/ai'; import type { AIQuestion, AIResponse } from '../types'; import { generateId } from '../utils/generateId'; +import { useSessionFetch } from './useSessionFetch'; export interface AIChatEntry { question: AIQuestion; @@ -105,6 +106,13 @@ export function useAIChat({ const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); + // Daemon architecture: all AI API calls go through the session-scoped fetch + // so `/api/ai/...` is rewritten to `/s//api/ai/...`. Kept in a ref + // so the stable callbacks below always use the latest scoped fetch. + const sessionFetch = useSessionFetch(); + const fetchRef = useRef(sessionFetch); + fetchRef.current = sessionFetch; + const abortRef = useRef(null); const sessionEpochRef = useRef(0); const createRequestRef = useRef(0); @@ -131,7 +139,7 @@ export function useAIChat({ const requestId = ++createRequestRef.current; setIsCreatingSession(true); try { - const res = await fetch('/api/ai/session', { + const res = await fetchRef.current('/api/ai/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -150,7 +158,7 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - fetch('/api/ai/abort', { + fetchRef.current('/api/ai/abort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: data.sessionId }), @@ -210,7 +218,7 @@ export function useAIChat({ } const fullPrompt = buildPrompt(params); - const res = await fetch('/api/ai/query', { + const res = await fetchRef.current('/api/ai/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -347,7 +355,7 @@ export function useAIChat({ } if (sessionIdRef.current) { - fetch('/api/ai/abort', { + fetchRef.current('/api/ai/abort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sessionIdRef.current }), @@ -362,7 +370,7 @@ export function useAIChat({ prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p) ); - fetch('/api/ai/permission', { + fetchRef.current('/api/ai/permission', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/packages/ui/hooks/useAgentJobs.ts b/packages/ui/hooks/useAgentJobs.ts index b2591ede9..9010a5757 100644 --- a/packages/ui/hooks/useAgentJobs.ts +++ b/packages/ui/hooks/useAgentJobs.ts @@ -1,22 +1,23 @@ /** - * Real-time agent job state via SSE with polling fallback. + * Real-time agent job state via the daemon WebSocket hub. * - * Primary transport: EventSource on /api/agents/jobs/stream. - * Fallback: version-gated GET polling if SSE fails. - * - * Mirrors packages/ui/hooks/useExternalAnnotations.ts in structure. + * Uses the same daemon WebSocket + HTTP polling transport as external annotations. * * Gated by an `enabled` option — callers pass their API-mode signal - * to avoid SSE/polling in static or demo contexts where there is no server. + * to avoid WebSocket/HTTP polling in static or demo contexts where there is no server. */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from '../types'; +import type { AgentJobInfo, AgentJobEvent, AgentCapabilities, AgentJobLogs } from '../types'; +import { + type DaemonSessionTransportMessage, + useDaemonSessionTransport, +} from './useDaemonSessionTransport'; +import { useSessionFetch } from './useSessionFetch'; -const POLL_INTERVAL_MS = 500; -const STREAM_URL = '/api/agents/jobs/stream'; const JOBS_URL = '/api/agents/jobs'; const CAPABILITIES_URL = '/api/agents/capabilities'; +const FALLBACK_POLL_MS = 2_000; interface UseAgentJobsReturn { jobs: AgentJobInfo[]; @@ -27,136 +28,110 @@ interface UseAgentJobsReturn { killAll: () => Promise; } +interface AgentJobsSnapshot { + jobs: AgentJobInfo[]; + logs?: AgentJobLogs; + version?: number; +} + +function logsToMap(logs: AgentJobLogs | undefined): Map { + if (!logs) return new Map(); + return new Map( + Object.entries(logs).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ); +} + export function useAgentJobs( options?: { enabled?: boolean }, ): UseAgentJobsReturn { + const fetch = useSessionFetch(); const enabled = options?.enabled ?? true; const [jobs, setJobs] = useState([]); const [jobLogs, setJobLogs] = useState>(new Map()); const [capabilities, setCapabilities] = useState(null); - const versionRef = useRef(0); - const fallbackRef = useRef(false); - const pollTimerRef = useRef | null>(null); - const receivedSnapshotRef = useRef(false); + const versionRef = useRef(null); // Fetch capabilities once on mount useEffect(() => { if (!enabled) return; + let cancelled = false; fetch(CAPABILITIES_URL) .then((res) => res.json()) .then((data) => { - if (data && Array.isArray(data.providers)) { + if (!cancelled && data && Array.isArray(data.providers)) { setCapabilities(data as AgentCapabilities); } }) .catch(() => { // Silent — capabilities unavailable }); - }, [enabled]); - - // SSE + polling for job state - useEffect(() => { - if (!enabled) return; - let cancelled = false; - receivedSnapshotRef.current = false; - fallbackRef.current = false; - - // --- SSE primary transport --- - const es = new EventSource(STREAM_URL); - - es.onmessage = (event) => { - if (cancelled) return; - - try { - const parsed: AgentJobEvent = JSON.parse(event.data); - - switch (parsed.type) { - case 'snapshot': - receivedSnapshotRef.current = true; - setJobs(parsed.jobs); - break; - case 'job:started': - setJobs((prev) => [...prev, parsed.job]); - break; - case 'job:updated': - case 'job:completed': - setJobs((prev) => - prev.map((j) => (j.id === parsed.job.id ? parsed.job : j)), - ); - break; - case 'job:log': - setJobLogs((prev) => { - const next = new Map(prev); - next.set(parsed.jobId, (prev.get(parsed.jobId) ?? '') + parsed.delta); - return next; - }); - break; - case 'jobs:cleared': - // No-op: killAll() already broadcasts individual job:completed events - // for each killed job, so the UI updates incrementally. - break; - } - } catch { - // Ignore malformed events (e.g., heartbeat comments) - } - }; - - es.onerror = () => { - // If we never received a snapshot, SSE isn't working — fall back to polling - if (!receivedSnapshotRef.current && !fallbackRef.current) { - fallbackRef.current = true; - es.close(); - startPolling(); - } - // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot + return () => { + cancelled = true; }; + }, [enabled]); - // --- Polling fallback --- - function startPolling() { - if (cancelled) return; - - fetchSnapshot(); - - pollTimerRef.current = setInterval(() => { - if (cancelled) return; - fetchSnapshot(); - }, POLL_INTERVAL_MS); + const applyEvent = useCallback((parsed: AgentJobEvent) => { + switch (parsed.type) { + case 'snapshot': + setJobs(parsed.jobs); + setJobLogs(logsToMap(parsed.logs)); + break; + case 'job:started': + setJobs((prev) => [...prev, parsed.job]); + break; + case 'job:updated': + case 'job:completed': + setJobs((prev) => + prev.map((j) => (j.id === parsed.job.id ? parsed.job : j)), + ); + break; + case 'job:log': + setJobLogs((prev) => { + const next = new Map(prev); + next.set(parsed.jobId, (prev.get(parsed.jobId) ?? '') + parsed.delta); + return next; + }); + break; + case 'jobs:cleared': + // No-op: killAll() already broadcasts individual job:completed events + // for each killed job, so the UI updates incrementally. + break; } + }, []); - async function fetchSnapshot() { - try { - const url = - versionRef.current > 0 - ? `${JOBS_URL}?since=${versionRef.current}` - : JOBS_URL; - - const res = await fetch(url); + const fetchSnapshot = useCallback(async (): Promise => { + const version = versionRef.current; + const url = version === null ? JOBS_URL : `${JOBS_URL}?since=${version}`; + const res = await fetch(url); + if (res.status === 304 || !res.ok) return null; + const data = await res.json(); + if (!data || !Array.isArray(data.jobs)) return null; + return data as AgentJobsSnapshot; + }, []); - if (res.status === 304) return; - if (!res.ok) return; + const applySnapshot = useCallback((snapshot: AgentJobsSnapshot) => { + setJobs(snapshot.jobs); + setJobLogs(logsToMap(snapshot.logs)); + if (typeof snapshot.version === 'number') versionRef.current = snapshot.version; + }, []); - const data = await res.json(); - if (Array.isArray(data.jobs)) { - setJobs(data.jobs); - } - if (typeof data.version === 'number') { - versionRef.current = data.version; - } - } catch { - // Silent — next poll will retry - } + const applyMessage = useCallback((message: DaemonSessionTransportMessage) => { + const event = message.payload as AgentJobEvent; + if (event.type === 'snapshot' && typeof event.version === 'number') { + versionRef.current = event.version; } - - return () => { - cancelled = true; - es.close(); - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - }; - }, [enabled]); + applyEvent(event); + }, [applyEvent]); + + useDaemonSessionTransport({ + enabled, + family: 'agent-jobs', + pollMs: FALLBACK_POLL_MS, + fetchSnapshot, + applySnapshot, + applyMessage, + }); const launchJob = useCallback( async (params: { @@ -191,7 +166,7 @@ export function useAgentJobs( method: 'DELETE', }); } catch { - // SSE will reconcile + // Live updates or fallback snapshots will reconcile } }, []); @@ -199,7 +174,7 @@ export function useAgentJobs( try { await fetch(JOBS_URL, { method: 'DELETE' }); } catch { - // SSE will reconcile + // Live updates or fallback snapshots will reconcile } }, []); diff --git a/packages/ui/hooks/useAgents.ts b/packages/ui/hooks/useAgents.ts index 73667a8b3..5c4b89b11 100644 --- a/packages/ui/hooks/useAgents.ts +++ b/packages/ui/hooks/useAgents.ts @@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react'; import type { Origin } from '@plannotator/shared/agents'; import { getAgentSwitchSettings } from '../utils/agentSwitch'; +import { useSessionFetch } from './useSessionFetch'; export interface Agent { id: string; @@ -26,6 +27,7 @@ export interface UseAgentsResult { * Only fetches when origin is 'opencode' */ export function useAgents(origin: Origin | null): UseAgentsResult { + const fetch = useSessionFetch(); const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index 9bae05c77..84ed551d8 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -1,17 +1,18 @@ /** - * Auto-save annotation drafts to the server. + * Auto-save and auto-restore annotation drafts. * * Stores full Annotation[] objects directly (preserving all fields - * including `source`, `id`, offsets, and meta). On mount, checks for - * an existing draft and exposes banner state for the UI to offer restoration. + * including `source`, `id`, offsets, and meta). On mount, if a draft + * exists, it is restored silently via the onRestore callback. * * Backward compatible: loads old tuple-serialized drafts via fromShareable(). */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import type { Annotation, CodeAnnotation, ImageAttachment } from '../types'; import { fromShareable, parseShareableImages } from '../utils/sharing'; import type { ShareableAnnotation } from '../utils/sharing'; +import { useSessionFetch } from './useSessionFetch'; const DEBOUNCE_MS = 500; @@ -35,17 +36,6 @@ function isLegacyDraft(data: unknown): data is LegacyDraftData { return !!data && typeof data === 'object' && 'a' in data && Array.isArray((data as LegacyDraftData).a); } -function formatTimeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; - const days = Math.floor(hours / 24); - return `${days} day${days !== 1 ? 's' : ''} ago`; -} - interface UseAnnotationDraftOptions { annotations: Annotation[]; codeAnnotations?: CodeAnnotation[]; @@ -53,12 +43,7 @@ interface UseAnnotationDraftOptions { isApiMode: boolean; isSharedSession: boolean; submitted: boolean; -} - -interface UseAnnotationDraftResult { - draftBanner: { count: number; timeAgo: string } | null; - restoreDraft: () => { annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] }; - dismissDraft: () => void; + onRestore: (annotations: Annotation[], codeAnnotations: CodeAnnotation[], globalAttachments: ImageAttachment[]) => void; } export function useAnnotationDraft({ @@ -68,15 +53,16 @@ export function useAnnotationDraft({ isApiMode, isSharedSession, submitted, -}: UseAnnotationDraftOptions): UseAnnotationDraftResult { - const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null); - const draftDataRef = useRef<{ annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] } | null>(null); + onRestore, +}: UseAnnotationDraftOptions): void { + const fetch = useSessionFetch(); const timerRef = useRef | null>(null); const hasMountedRef = useRef(false); + const restoredRef = useRef(false); - // Load draft on mount + // Load and auto-restore draft on mount useEffect(() => { - if (!isApiMode || isSharedSession) return; + if (!isApiMode || isSharedSession || restoredRef.current) return; fetch('/api/draft') .then(res => { @@ -94,11 +80,9 @@ export function useAnnotationDraft({ let restoredGlobal: ImageAttachment[]; if (isLegacyDraft(data)) { - // Old tuple format — deserialize via fromShareable restoredAnnotations = data.a.length > 0 ? fromShareable(data.a, data.d) : []; restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters[0]) ?? []) : []; } else if (Array.isArray(data.annotations)) { - // New direct-object format restoredAnnotations = data.annotations; restoredCodeAnnotations = Array.isArray(data.codeAnnotations) ? data.codeAnnotations : []; restoredGlobal = Array.isArray(data.globalAttachments) ? data.globalAttachments : []; @@ -113,11 +97,8 @@ export function useAnnotationDraft({ const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length; if (totalCount > 0) { - draftDataRef.current = { annotations: restoredAnnotations, codeAnnotations: restoredCodeAnnotations, globalAttachments: restoredGlobal }; - setDraftBanner({ - count: totalCount, - timeAgo: formatTimeAgo(data.ts || 0), - }); + restoredRef.current = true; + onRestore(restoredAnnotations, restoredCodeAnnotations, restoredGlobal); } hasMountedRef.current = true; }) @@ -146,34 +127,11 @@ export function useAnnotationDraft({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }).catch(() => { - // Silent failure — draft is best-effort - }); + }).catch(() => {}); }, DEBOUNCE_MS); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [annotations, codeAnnotations, globalAttachments, isApiMode, isSharedSession, submitted]); - - const restoreDraft = useCallback(() => { - const data = draftDataRef.current; - setDraftBanner(null); - draftDataRef.current = null; - - if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [] }; - - return data; - }, []); - - const dismissDraft = useCallback(() => { - setDraftBanner(null); - draftDataRef.current = null; - - fetch('/api/draft', { method: 'DELETE' }).catch(() => { - // Silent failure - }); - }, []); - - return { draftBanner, restoreDraft, dismissDraft }; } diff --git a/packages/ui/hooks/useArchive.ts b/packages/ui/hooks/useArchive.ts deleted file mode 100644 index 9bcf2e101..000000000 --- a/packages/ui/hooks/useArchive.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Archive Hook - * - * Manages state and handlers for the archive browser — both standalone - * archive mode and in-session browsing via the sidebar tab. - */ - -import { useState, useRef, useMemo, useCallback } from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; -import type { UseLinkedDocReturn } from "./useLinkedDoc"; -import type { ViewerHandle } from "../components/Viewer"; -import type { Annotation } from "../types"; -import { getPlanSaveSettings } from "../utils/planSave"; - -export interface UseArchiveOptions { - markdown: string; - viewerRef: React.RefObject; - linkedDocHook: UseLinkedDocReturn; - setMarkdown: (md: string) => void; - setAnnotations: (anns: Annotation[]) => void; - setSelectedAnnotationId: (id: string | null) => void; - setSubmitted: (s: "approved" | "denied" | null) => void; -} - -export interface UseArchiveReturn { - /** Whether running in standalone archive mode */ - archiveMode: boolean; - /** List of archived plans */ - plans: ArchivedPlan[]; - /** Currently selected archive filename */ - selectedFile: string | null; - /** Whether the plan list is loading */ - isLoading: boolean; - /** Info about the currently selected archive plan */ - currentInfo: { status: ArchivedPlan["status"]; timestamp: string; title: string } | null; - /** Initialize archive state from /api/plan response */ - init: (plans: ArchivedPlan[]) => void; - /** Select an archive plan to view */ - select: (filename: string) => Promise; - /** Lazy-fetch the plan list (for in-session mode) */ - fetchPlans: () => Promise; - /** Close the archive browser */ - done: () => Promise; - /** Copy plan content to clipboard (strips annotations) */ - copy: () => void; - /** Clear the selected file (used by linked doc back) */ - clearSelection: () => void; -} - -export function useArchive(options: UseArchiveOptions): UseArchiveReturn { - const { - markdown, - viewerRef, - linkedDocHook, - setMarkdown, - setAnnotations, - setSelectedAnnotationId, - setSubmitted, - } = options; - - const [archiveMode, setArchiveMode] = useState(false); - const [plans, setPlans] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const hasFetched = useRef(false); - - const customPath = useMemo(() => getPlanSaveSettings().customPath || undefined, []); - - const currentInfo = useMemo(() => { - if (!selectedFile) return null; - const plan = plans.find(p => p.filename === selectedFile); - return plan ? { status: plan.status, timestamp: plan.timestamp, title: plan.title } : null; - }, [selectedFile, plans]); - - const init = useCallback((archivePlans: ArchivedPlan[]) => { - setArchiveMode(true); - setPlans(archivePlans); - if (archivePlans.length > 0) { - setSelectedFile(archivePlans[0].filename); - } - }, []); - - const select = useCallback(async (filename: string) => { - try { - const params = new URLSearchParams({ filename }); - if (customPath) params.set("customPath", customPath); - const res = await fetch(`/api/archive/plan?${params}`); - if (!res.ok) return; - const data = await res.json() as { markdown: string; filepath: string }; - - if (archiveMode) { - // Standalone: direct swap - viewerRef.current?.clearAllHighlights(); - setMarkdown(data.markdown); - setAnnotations([]); - setSelectedAnnotationId(null); - setSelectedFile(filename); - } else { - // In-session: use linked doc overlay - const buildUrl = (f: string) => { - const p = new URLSearchParams({ filename: f }); - if (customPath) p.set("customPath", customPath); - return `/api/archive/plan?${p}`; - }; - linkedDocHook.open(filename, buildUrl, "archive"); - setSelectedFile(filename); - } - } catch { /* ignore */ } - }, [archiveMode, customPath, viewerRef, setMarkdown, setAnnotations, setSelectedAnnotationId, linkedDocHook]); - - const fetchPlans = useCallback(async () => { - if (hasFetched.current || isLoading) return; - hasFetched.current = true; - setIsLoading(true); - try { - const params = new URLSearchParams(); - if (customPath) params.set("customPath", customPath); - const qs = params.toString(); - const res = await fetch(`/api/archive/plans${qs ? `?${qs}` : ""}`); - if (!res.ok) return; - const data = await res.json() as { plans: ArchivedPlan[] }; - setPlans(data.plans); - // In standalone archive mode, auto-select and load the first plan - // so the viewer reflects the customPath results, not stale server data - if (archiveMode && data.plans.length > 0) { - const first = data.plans[0].filename; - setSelectedFile(first); - const fetchParams = new URLSearchParams({ filename: first }); - if (customPath) fetchParams.set("customPath", customPath); - const planRes = await fetch(`/api/archive/plan?${fetchParams}`); - if (planRes.ok) { - const planData = await planRes.json() as { markdown: string }; - setMarkdown(planData.markdown); - } - } - } catch { - hasFetched.current = false; - } finally { - setIsLoading(false); - } - }, [archiveMode, customPath, isLoading, setMarkdown]); - - const done = useCallback(async () => { - try { - await fetch("/api/done", { method: "POST" }); - setSubmitted("approved"); - } catch { /* ignore */ } - }, [setSubmitted]); - - const copy = useCallback(() => { - navigator.clipboard.writeText(markdown); - }, [markdown]); - - const clearSelection = useCallback(() => { - setSelectedFile(null); - }, []); - - return { - archiveMode, - plans, - selectedFile, - isLoading, - currentInfo, - init, - select, - fetchPlans, - done, - copy, - clearSelection, - }; -} diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index 72bdde77d..39f3fa01e 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -1,12 +1,14 @@ /** - * Auto-save code review annotation drafts to the server. + * Auto-save and auto-restore code review annotation drafts. * - * Similar to useAnnotationDraft but stores CodeAnnotation[] directly - * (they're already compact — no tuple conversion needed). + * Drafts are keyed by a content hash of the diff on the server side. + * Same diff = same draft. On mount, if a draft exists, it is restored + * silently — no dialog, no user action needed. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import type { CodeAnnotation } from '../types'; +import { useSessionFetch } from './useSessionFetch'; const DEBOUNCE_MS = 500; @@ -16,28 +18,12 @@ interface DraftData { ts: number; } -function formatTimeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; - const days = Math.floor(hours / 24); - return `${days} day${days !== 1 ? 's' : ''} ago`; -} - interface UseCodeAnnotationDraftOptions { annotations: CodeAnnotation[]; viewedFiles: Set; isApiMode: boolean; submitted: boolean; -} - -interface UseCodeAnnotationDraftResult { - draftBanner: { count: number; viewedCount: number; timeAgo: string } | null; - restoreDraft: () => { annotations: CodeAnnotation[]; viewedFiles: string[] }; - dismissDraft: () => void; + onRestore: (annotations: CodeAnnotation[], viewedFiles: string[]) => void; } export function useCodeAnnotationDraft({ @@ -45,15 +31,17 @@ export function useCodeAnnotationDraft({ viewedFiles, isApiMode, submitted, -}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult { - const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null); - const draftDataRef = useRef(null); + onRestore, +}: UseCodeAnnotationDraftOptions): void { + const fetch = useSessionFetch(); const timerRef = useRef | null>(null); const hasMountedRef = useRef(false); + const restoredRef = useRef(false); + const draftExistsOnServerRef = useRef(false); - // Load draft on mount + // Load and auto-restore draft on mount useEffect(() => { - if (!isApiMode) return; + if (!isApiMode || restoredRef.current) return; fetch('/api/draft') .then(res => { @@ -61,15 +49,12 @@ export function useCodeAnnotationDraft({ return res.json(); }) .then((data: DraftData | null) => { - const annotationCount = Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0; - const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0; - if (annotationCount > 0 || viewedCount > 0) { - draftDataRef.current = data; - setDraftBanner({ - count: annotationCount, - viewedCount, - timeAgo: formatTimeAgo(data?.ts || 0), - }); + const restoredAnnotations = Array.isArray(data?.codeAnnotations) ? data.codeAnnotations : []; + const restoredViewed = Array.isArray(data?.viewedFiles) ? data.viewedFiles : []; + if (restoredAnnotations.length > 0 || restoredViewed.length > 0) { + restoredRef.current = true; + draftExistsOnServerRef.current = true; + onRestore(restoredAnnotations, restoredViewed); } hasMountedRef.current = true; }) @@ -82,10 +67,19 @@ export function useCodeAnnotationDraft({ useEffect(() => { if (!isApiMode || submitted) return; if (!hasMountedRef.current) return; - if (annotations.length === 0 && viewedFiles.size === 0) return; if (timerRef.current) clearTimeout(timerRef.current); + if (annotations.length === 0 && viewedFiles.size === 0) { + if (draftExistsOnServerRef.current) { + timerRef.current = setTimeout(() => { + fetch('/api/draft', { method: 'DELETE' }).catch(() => {}); + draftExistsOnServerRef.current = false; + }, DEBOUNCE_MS); + } + return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + } + timerRef.current = setTimeout(() => { const payload: DraftData = { codeAnnotations: annotations, @@ -97,31 +91,11 @@ export function useCodeAnnotationDraft({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }).catch(() => { - // Silent failure - }); + }).then(() => { draftExistsOnServerRef.current = true; }).catch(() => {}); }, DEBOUNCE_MS); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [annotations, viewedFiles, isApiMode, submitted]); - - const restoreDraft = useCallback(() => { - const data = draftDataRef.current; - setDraftBanner(null); - draftDataRef.current = null; - return { - annotations: data?.codeAnnotations ?? [], - viewedFiles: data?.viewedFiles ?? [], - }; - }, []); - - const dismissDraft = useCallback(() => { - setDraftBanner(null); - draftDataRef.current = null; - fetch('/api/draft', { method: 'DELETE' }).catch(() => {}); - }, []); - - return { draftBanner, restoreDraft, dismissDraft }; } diff --git a/packages/ui/hooks/useDaemonSessionTransport.ts b/packages/ui/hooks/useDaemonSessionTransport.ts new file mode 100644 index 000000000..37b373d49 --- /dev/null +++ b/packages/ui/hooks/useDaemonSessionTransport.ts @@ -0,0 +1,120 @@ +import { useEffect } from 'react'; +import type { + DaemonEventFamily, + DaemonWebSocketServerMessage, +} from '@plannotator/shared/daemon-protocol'; +import { subscribeToDaemonSessionFamily } from '../utils/daemonHub'; + +type SessionEventFamily = Exclude; +export type DaemonSessionTransportMessage = Extract< + DaemonWebSocketServerMessage, + { type: 'snapshot' | 'event' } +>; + +const CONNECTING_FALLBACK_MS = 1_000; + +interface UseDaemonSessionTransportOptions { + enabled: boolean; + family: SessionEventFamily; + pollMs: number; + fetchSnapshot: () => Promise; + applySnapshot: (snapshot: TSnapshot) => void; + applyMessage: (message: DaemonSessionTransportMessage) => void; +} + +export function useDaemonSessionTransport({ + enabled, + family, + pollMs, + fetchSnapshot, + applySnapshot, + applyMessage, +}: UseDaemonSessionTransportOptions): void { + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let socketOpen = false; + let transportGeneration = 0; + let pollTimer: ReturnType | undefined; + let connectingFallbackTimer: ReturnType | undefined; + + const fetchAndApplySnapshot = async () => { + const startedWhileSocketOpen = socketOpen; + const startedGeneration = transportGeneration; + let snapshot: TSnapshot | null | undefined; + try { + snapshot = await fetchSnapshot(); + } catch { + return; + } + if ( + !cancelled && + !socketOpen && + !startedWhileSocketOpen && + startedGeneration === transportGeneration && + snapshot !== null && + snapshot !== undefined + ) { + applySnapshot(snapshot); + } + }; + + const startPolling = () => { + if (pollTimer) return; + void fetchAndApplySnapshot(); + pollTimer = setInterval(() => void fetchAndApplySnapshot(), pollMs); + }; + + const stopPolling = () => { + if (!pollTimer) return; + clearInterval(pollTimer); + pollTimer = undefined; + }; + + const stopConnectingFallback = () => { + if (!connectingFallbackTimer) return; + clearTimeout(connectingFallbackTimer); + connectingFallbackTimer = undefined; + }; + + const startConnectingFallback = () => { + if (connectingFallbackTimer || pollTimer) return; + connectingFallbackTimer = setTimeout(() => { + connectingFallbackTimer = undefined; + if (!socketOpen) startPolling(); + }, CONNECTING_FALLBACK_MS); + }; + + const unsubscribe = subscribeToDaemonSessionFamily( + family, + (message) => { + if (cancelled || (message.type !== 'snapshot' && message.type !== 'event')) return; + applyMessage(message); + }, + (state) => { + if (state === 'open') { + socketOpen = true; + transportGeneration += 1; + stopConnectingFallback(); + stopPolling(); + return; + } + socketOpen = false; + if (state === 'connecting') { + startConnectingFallback(); + } else if (state === 'closed' || state === 'unavailable') { + stopConnectingFallback(); + startPolling(); + } + }, + ); + if (!unsubscribe) startPolling(); + + return () => { + cancelled = true; + unsubscribe?.(); + stopConnectingFallback(); + stopPolling(); + }; + }, [applyMessage, applySnapshot, enabled, family, fetchSnapshot, pollMs]); +} diff --git a/packages/ui/hooks/useEditorAnnotations.ts b/packages/ui/hooks/useEditorAnnotations.ts index 0265be3cf..01c595989 100644 --- a/packages/ui/hooks/useEditorAnnotations.ts +++ b/packages/ui/hooks/useEditorAnnotations.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { EditorAnnotation } from '../types'; +import { useSessionFetch } from './useSessionFetch'; const POLL_INTERVAL = 500; const IS_VSCODE = typeof window !== 'undefined' && (window as any).__PLANNOTATOR_VSCODE === true; @@ -17,6 +18,7 @@ interface UseEditorAnnotationsReturn { * contexts, returns an empty array with zero network cost. */ export function useEditorAnnotations(): UseEditorAnnotationsReturn { + const fetch = useSessionFetch(); const [annotations, setAnnotations] = useState([]); const intervalRef = useRef | null>(null); diff --git a/packages/ui/hooks/useExternalAnnotationHighlights.ts b/packages/ui/hooks/useExternalAnnotationHighlights.ts index 2d5cdf021..8d473f053 100644 --- a/packages/ui/hooks/useExternalAnnotationHighlights.ts +++ b/packages/ui/hooks/useExternalAnnotationHighlights.ts @@ -4,7 +4,7 @@ import { AnnotationType } from '../types'; import type { ViewerHandle } from '../components/Viewer'; /** - * Bridges SSE-delivered external annotations into the Viewer's imperative + * Bridges live external annotations into the Viewer's imperative * highlight API so tools can POST annotations with `originalText` and have * them highlight real spans of the rendered plan. * @@ -21,7 +21,7 @@ import type { ViewerHandle } from '../components/Viewer'; * which would otherwise leave our bookkeeping stale against a wiped DOM. * - Disabled state no-ops WITHOUT clearing the applied set. This preserves the * bookkeeping while the Viewer DOM is hidden (diff view / linked doc) so that - * any SSE removals that arrive while hidden are correctly reconciled when the + * any live removals that arrive while hidden are correctly reconciled when the * hook re-enables. */ export function useExternalAnnotationHighlights(params: { diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 467158a12..4cab80b20 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -1,23 +1,24 @@ /** - * Real-time external annotations via SSE with polling fallback. - * - * Primary transport: EventSource on /api/external-annotations/stream. - * Fallback: version-gated GET polling if SSE fails (e.g., proxy environments). + * Real-time external annotations via the daemon WebSocket hub. * * Generic over the annotation type — plan editor uses Annotation, * review editor uses CodeAnnotation. The hook is shape-agnostic; * it just serializes/deserializes JSON. * * Gated by an `enabled` option — callers pass their API-mode signal - * to avoid SSE/polling in static or demo contexts where there is no server. + * to avoid WebSocket/HTTP polling in static or demo contexts where there is no server. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef } from 'react'; import type { ExternalAnnotationEvent } from '../types'; +import { + type DaemonSessionTransportMessage, + useDaemonSessionTransport, +} from './useDaemonSessionTransport'; +import { useSessionFetch } from './useSessionFetch'; -const POLL_INTERVAL_MS = 500; -const STREAM_URL = '/api/external-annotations/stream'; const SNAPSHOT_URL = '/api/external-annotations'; +const FALLBACK_POLL_MS = 2_000; interface UseExternalAnnotationsReturn { externalAnnotations: T[]; @@ -26,116 +27,78 @@ interface UseExternalAnnotationsReturn { clearExternalAnnotations: (source?: string) => void; } +interface ExternalAnnotationSnapshot { + annotations: T[]; + version?: number; +} + export function useExternalAnnotations( options?: { enabled?: boolean }, ): UseExternalAnnotationsReturn { + const fetch = useSessionFetch(); const enabled = options?.enabled ?? true; const [annotations, setAnnotations] = useState([]); - const versionRef = useRef(0); - const fallbackRef = useRef(false); - const pollTimerRef = useRef | null>(null); - const receivedSnapshotRef = useRef(false); - - useEffect(() => { - if (!enabled) return; - let cancelled = false; - - // --- SSE primary transport --- - const es = new EventSource(STREAM_URL); - - es.onmessage = (event) => { - if (cancelled) return; - - try { - const parsed: ExternalAnnotationEvent = JSON.parse(event.data); - - switch (parsed.type) { - case 'snapshot': - receivedSnapshotRef.current = true; - setAnnotations(parsed.annotations); - break; - case 'add': - setAnnotations((prev) => [...prev, ...parsed.annotations]); - break; - case 'remove': - setAnnotations((prev) => - prev.filter((a) => !parsed.ids.includes(a.id)), - ); - break; - case 'clear': - setAnnotations((prev) => - parsed.source - ? prev.filter((a) => a.source !== parsed.source) - : [], - ); - break; - case 'update': - setAnnotations((prev) => - prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), - ); - break; - } - } catch { - // Ignore malformed events (e.g., heartbeat comments) - } - }; - - es.onerror = () => { - // If we never received a snapshot, SSE isn't working — fall back to polling - if (!receivedSnapshotRef.current && !fallbackRef.current) { - fallbackRef.current = true; - es.close(); - startPolling(); - } - // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot - }; - - // --- Polling fallback --- - function startPolling() { - if (cancelled) return; - - // Initial fetch - fetchSnapshot(); - - pollTimerRef.current = setInterval(() => { - if (cancelled) return; - fetchSnapshot(); - }, POLL_INTERVAL_MS); + const versionRef = useRef(null); + + const applyEvent = useCallback((parsed: ExternalAnnotationEvent) => { + switch (parsed.type) { + case 'snapshot': + setAnnotations(parsed.annotations); + break; + case 'add': + setAnnotations((prev) => [...prev, ...parsed.annotations]); + break; + case 'remove': + setAnnotations((prev) => + prev.filter((a) => !parsed.ids.includes(a.id)), + ); + break; + case 'clear': + setAnnotations((prev) => + parsed.source + ? prev.filter((a) => a.source !== parsed.source) + : [], + ); + break; + case 'update': + setAnnotations((prev) => + prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), + ); + break; } + }, []); - async function fetchSnapshot() { - try { - const url = - versionRef.current > 0 - ? `${SNAPSHOT_URL}?since=${versionRef.current}` - : SNAPSHOT_URL; - - const res = await fetch(url); + const fetchSnapshot = useCallback(async (): Promise | null> => { + const version = versionRef.current; + const url = version === null ? SNAPSHOT_URL : `${SNAPSHOT_URL}?since=${version}`; + const res = await fetch(url); + if (res.status === 304 || !res.ok) return null; + const data = await res.json(); + if (!data || !Array.isArray(data.annotations)) return null; + return data as ExternalAnnotationSnapshot; + }, []); - if (res.status === 304) return; // No changes - if (!res.ok) return; + const applySnapshot = useCallback((snapshot: ExternalAnnotationSnapshot) => { + setAnnotations(snapshot.annotations); + if (typeof snapshot.version === 'number') versionRef.current = snapshot.version; + }, []); - const data = await res.json(); - if (Array.isArray(data.annotations)) { - setAnnotations(data.annotations); - } - if (typeof data.version === 'number') { - versionRef.current = data.version; - } - } catch { - // Silent — next poll will retry - } + const applyMessage = useCallback((message: DaemonSessionTransportMessage) => { + const event = message.payload as ExternalAnnotationEvent; + if (event.type === 'snapshot' && typeof event.version === 'number') { + versionRef.current = event.version; } - - return () => { - cancelled = true; - es.close(); - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - }; - }, [enabled]); + applyEvent(event); + }, [applyEvent]); + + useDaemonSessionTransport({ + enabled, + family: 'external-annotations', + pollMs: FALLBACK_POLL_MS, + fetchSnapshot, + applySnapshot, + applyMessage, + }); const deleteExternalAnnotation = useCallback(async (id: string) => { // Optimistic update @@ -146,7 +109,7 @@ export function useExternalAnnotations void; fetchTree: (dirPath: string) => void; fetchAll: (directories: string[]) => void; - addVaultDir: (vaultPath: string) => void; - clearVaultDirs: () => void; activeFile: string | null; activeDirPath: string | null; setActiveFile: (path: string | null) => void; } export function useFileBrowser(): UseFileBrowserReturn { + const fetch = useSessionFetch(); const [dirs, setDirs] = useState([]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [activeFile, setActiveFile] = useState(null); @@ -103,74 +99,18 @@ export function useFileBrowser(): UseFileBrowserReturn { const fetchAll = useCallback( (directories: string[]) => { - setDirs((prev) => { - // Preserve any vault dirs that were already loaded - const vaultDirs = prev.filter((d) => d.isVault); - const regularDirs = directories.map((path) => ({ - path, - name: path.split("/").pop() || path, - tree: [], - isLoading: false, - error: null, - })); - return [...regularDirs, ...vaultDirs]; - }); + setDirs(directories.map((path) => ({ + path, + name: path.split("/").pop() || path, + tree: [], + isLoading: false, + error: null, + }))); directories.forEach((d) => fetchTree(d)); }, [fetchTree] ); - const clearVaultDirs = useCallback(() => { - setDirs((prev) => prev.filter((d) => !d.isVault)); - }, []); - - const addVaultDir = useCallback(async (vaultPath: string) => { - const name = vaultPath.split("/").pop() || vaultPath; - - // Atomically replace any existing vault dirs (handles vault path change without accumulating stale entries) - setDirs((prev) => { - const nonVaultDirs = prev.filter((d) => !d.isVault); - return [...nonVaultDirs, { path: vaultPath, name, tree: [], isLoading: true, error: null, isVault: true }]; - }); - - try { - const res = await fetch( - `/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}` - ); - const data = await res.json(); - - if (!res.ok || data.error) { - setDirs((prev) => - prev.map((d) => - d.path === vaultPath ? { ...d, isLoading: false, error: data.error || "Failed to load" } : d - ) - ); - return; - } - - setDirs((prev) => - prev.map((d) => - d.path === vaultPath ? { ...d, tree: data.tree, isLoading: false, isVault: true } : d - ) - ); - - const rootFolders = (data.tree as VaultNode[]) - .filter((n) => n.type === "folder") - .map((n) => `${vaultPath}:${n.path}`); - setExpandedFolders((prev) => { - const next = new Set(prev); - rootFolders.forEach((f) => next.add(f)); - return next; - }); - } catch { - setDirs((prev) => - prev.map((d) => - d.path === vaultPath ? { ...d, isLoading: false, error: "Failed to connect to server" } : d - ) - ); - } - }, []); - const toggleFolder = useCallback((key: string) => { setExpandedFolders((prev) => { const next = new Set(prev); @@ -191,8 +131,6 @@ export function useFileBrowser(): UseFileBrowserReturn { toggleCollapse, fetchTree, fetchAll, - addVaultDir, - clearVaultDirs, activeFile, activeDirPath: activeFile ? (dirs.find((d) => activeFile.startsWith(d.path + "/"))?.path ?? null) : null, setActiveFile, diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index c7d3a3d41..008ec3c8f 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -7,6 +7,7 @@ */ import { useState, useCallback, useRef } from "react"; +import { useSessionFetch } from './useSessionFetch'; import type { Annotation, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; @@ -63,11 +64,14 @@ export interface UseLinkedDocReturn { getDocAnnotations: () => Map; /** Reactive count of annotations on non-active documents (updates on open() and back()) */ docAnnotationCount: number; + /** Clear all cached linked-doc annotations (used on session revision) */ + clearCache: () => void; } const HIGHLIGHT_REAPPLY_DELAY = 100; export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { + const fetch = useSessionFetch(); const { markdown, annotations, @@ -295,5 +299,6 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { dismissError, getDocAnnotations, docAnnotationCount, + clearCache: () => { docCache.current.clear(); setDocAnnotationCount(0); }, }; } diff --git a/packages/ui/hooks/usePlanDiff.ts b/packages/ui/hooks/usePlanDiff.ts index b5632b24b..13e0c51f9 100644 --- a/packages/ui/hooks/usePlanDiff.ts +++ b/packages/ui/hooks/usePlanDiff.ts @@ -11,6 +11,7 @@ import { type PlanDiffBlock, type PlanDiffStats, } from "../utils/planDiffEngine"; +import { useSessionFetch } from "./useSessionFetch"; export interface VersionInfo { version: number; @@ -53,6 +54,7 @@ export function usePlanDiff( initialPreviousPlan: string | null, versionInfo: VersionInfo | null ): UsePlanDiffReturn { + const fetch = useSessionFetch(); const [diffBasePlan, setDiffBasePlan] = useState( initialPreviousPlan ); @@ -64,16 +66,15 @@ export function usePlanDiff( const [isSelectingVersion, setIsSelectingVersion] = useState(false); const [fetchingVersion, setFetchingVersion] = useState(null); - // Sync diffBasePlan when initialPreviousPlan arrives after mount (API response) + // Sync diff base when previousPlan or versionInfo changes (initial load + resubmissions) useEffect(() => { - if (initialPreviousPlan && !diffBasePlan) { + if (initialPreviousPlan) { setDiffBasePlan(initialPreviousPlan); } }, [initialPreviousPlan]); - // Sync diffBaseVersion when versionInfo arrives after mount useEffect(() => { - if (versionInfo && versionInfo.version > 1 && diffBaseVersion === null) { + if (versionInfo && versionInfo.version > 1) { setDiffBaseVersion(versionInfo.version - 1); } }, [versionInfo]); diff --git a/packages/ui/hooks/useSessionFetch.test.tsx b/packages/ui/hooks/useSessionFetch.test.tsx new file mode 100644 index 000000000..26dbe724b --- /dev/null +++ b/packages/ui/hooks/useSessionFetch.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, describe, it, expect, mock, beforeEach } from 'bun:test'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { SessionProvider, useSessionFetch } from './useSessionFetch'; + +let captured: (input: string | URL | Request, init?: RequestInit) => Promise; +const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + +function Capture() { + captured = useSessionFetch(); + return null; +} + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + calls.length = 0; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + calls.push({ input, init }); + return Promise.resolve(new Response('ok')); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('useSessionFetch', () => { + describe('with SessionProvider', () => { + function render(sessionId: string) { + renderToString( + createElement(SessionProvider, { sessionId, children: createElement(Capture) }), + ); + } + + it('rewrites /api/ paths to session-scoped paths', () => { + render('sess_abc123'); + captured('/api/diff'); + expect(calls[0].input).toBe('/s/sess_abc123/api/diff'); + }); + + it('rewrites bare /api to session base', () => { + render('sess_1'); + captured('/api'); + expect(calls[0].input).toBe('/s/sess_1/api'); + }); + + it('preserves query strings', () => { + render('sess_1'); + captured('/api/external-annotations?since=5'); + expect(calls[0].input).toBe('/s/sess_1/api/external-annotations?since=5'); + }); + + it('passes through non-api paths unchanged', () => { + render('sess_1'); + captured('/daemon/status'); + expect(calls[0].input).toBe('/daemon/status'); + }); + + it('passes through external URLs unchanged', () => { + render('sess_1'); + captured('https://api.github.com/repos'); + expect(calls[0].input).toBe('https://api.github.com/repos'); + }); + + it('passes init options through', () => { + render('sess_1'); + const init = { method: 'POST', body: '{}' }; + captured('/api/feedback', init); + expect(calls[0].input).toBe('/s/sess_1/api/feedback'); + expect(calls[0].init).toBe(init); + }); + + it('handles template-literal paths with interpolation', () => { + render('sess_1'); + const jobId = 'job_42'; + captured(`/api/tour/${jobId}`); + expect(calls[0].input).toBe('/s/sess_1/api/tour/job_42'); + }); + }); + + describe('without SessionProvider', () => { + it('passes api paths through unchanged', () => { + renderToString(createElement(Capture)); + captured('/api/diff'); + expect(calls[0].input).toBe('/api/diff'); + }); + + it('passes non-api paths through unchanged', () => { + renderToString(createElement(Capture)); + captured('/daemon/status'); + expect(calls[0].input).toBe('/daemon/status'); + }); + }); +}); diff --git a/packages/ui/hooks/useSessionFetch.tsx b/packages/ui/hooks/useSessionFetch.tsx new file mode 100644 index 000000000..8abb70798 --- /dev/null +++ b/packages/ui/hooks/useSessionFetch.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useCallback, useEffect, type ReactNode } from 'react'; + +type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; + +interface SessionContextValue { + fetch: FetchFn; +} + +const UNSET = Symbol('no-session'); + +const SessionContext = createContext(UNSET); + +export function SessionProvider({ + sessionId, + children, +}: { + sessionId: string; + children: ReactNode; +}) { + const sessionFetch = useCallback( + (input, init) => { + if (typeof input === 'string') { + if (input === '/api' || input.startsWith('/api/')) { + return globalThis.fetch(`/s/${sessionId}/api${input.slice(4)}`, init); + } + } + return globalThis.fetch(input, init); + }, + [sessionId], + ); + + // Also set the window global so non-hook consumers (apiPath, ) work + useEffect(() => { + const prev = window.__PLANNOTATOR_API_BASE__; + window.__PLANNOTATOR_API_BASE__ = `/s/${sessionId}/api`; + return () => { + window.__PLANNOTATOR_API_BASE__ = prev; + }; + }, [sessionId]); + + return ( + + {children} + + ); +} + +export function useSessionFetch(): FetchFn { + const ctx = useContext(SessionContext); + if (ctx === UNSET) return globalThis.fetch; + return ctx.fetch; +} diff --git a/packages/ui/hooks/useSidebar.ts b/packages/ui/hooks/useSidebar.ts index 0424b2403..23e63e07c 100644 --- a/packages/ui/hooks/useSidebar.ts +++ b/packages/ui/hooks/useSidebar.ts @@ -8,7 +8,7 @@ import { useState, useCallback } from "react"; -export type SidebarTab = "toc" | "versions" | "files" | "archive"; +export type SidebarTab = "toc" | "versions" | "files"; export interface UseSidebarReturn { isOpen: boolean; diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 5b6c31be6..34bcfba18 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { extractCandidateCodePaths } from "@plannotator/shared/extract-code-paths"; +import { useSessionFetch } from "./useSessionFetch"; export type ValidationEntry = | { status: "found"; resolved: string } @@ -30,6 +31,7 @@ export function useValidatedCodePaths( markdown: string, baseDir?: string, ): { validated: ValidatedMap; ready: boolean } { + const fetch = useSessionFetch(); const [validated, setValidated] = useState(new Map()); const [ready, setReady] = useState(false); diff --git a/packages/ui/package.json b/packages/ui/package.json index 0f14707c7..83323f988 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,7 +38,8 @@ "motion": "^12.38.0", "react": "^19.2.3", "react-dom": "^19.2.3", - "unique-username-generator": "^1.5.1" + "unique-username-generator": "^1.5.1", + "zustand": "^5.0.13" }, "devDependencies": { "@types/bun": "^1.2.0", diff --git a/packages/ui/shortcuts.test.ts b/packages/ui/shortcuts.test.ts index 5dddb1984..81d5606e7 100644 --- a/packages/ui/shortcuts.test.ts +++ b/packages/ui/shortcuts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { annotateSettingsShortcutRegistry, planEditorShortcuts, planReviewSettingsShortcutRegistry } from '../editor/shortcuts'; -import { reviewSettingsShortcutRegistry } from '../review-editor/shortcuts'; +import { annotateSettingsShortcutRegistry, planEditorShortcuts, planReviewSettingsShortcutRegistry } from '../plannotator-plan-review/shortcuts'; +import { reviewSettingsShortcutRegistry } from '../plannotator-code-review/shortcuts'; import { createShortcutRegistry, defineShortcutScope, @@ -127,7 +127,6 @@ describe('shortcuts', () => { const handled = dispatchShortcutEvent(planReviewSettingsShortcutRegistry[0], { submitPlan: () => calls.push('submitPlan'), - quickSave: () => calls.push('quickSave'), }, event); expect(handled).toBe(true); diff --git a/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts b/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts index bb4358366..8a6230352 100644 --- a/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts +++ b/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts @@ -3,10 +3,9 @@ import { defineShortcutScope } from '../core'; // Heads-up for future migrators: these bindings (`Alt hold`, `Alt Alt`) are // declarative metadata for the help modal and marketing docs. They do NOT // dispatch through `useShortcutScope`. `Alt Alt` works via -// `useDoubleTapShortcuts`; `Alt hold` has no shared hook yet — it's wired -// today by the bespoke `useInputMethodSwitch` hook in -// `packages/editor/hooks/`. See the comment block above -// `useDoubleTapShortcuts` in `packages/ui/shortcuts/runtime.ts`. +// `useDoubleTapShortcuts`; `Alt hold` has no shared hook yet. See the +// comment block above `useDoubleTapShortcuts` in +// `packages/ui/shortcuts/runtime.ts`. export const inputMethodShortcuts = defineShortcutScope({ id: 'input-method', diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 415745ade..493d40335 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -50,6 +50,7 @@ @import "./themes/slack.css"; @import "./themes/snazzy-light.css"; @import "./themes/vitesse-black.css"; +@import "./themes/neutral.css"; @import "./print.css"; /* Tailwind bridge — maps CSS vars to utility classes */ @@ -312,6 +313,12 @@ html:not(.transitions-ready) * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } +/* Skip color transitions on hidden keep-alive surfaces for performance. + With multiple sessions mounted via visibility:hidden, animating + thousands of elements on theme change causes visible lag. */ +[style*="visibility: hidden"] * { + transition-duration: 0s !important; +} /* Focus states */ :focus-visible { diff --git a/packages/ui/themes/neutral.css b/packages/ui/themes/neutral.css new file mode 100644 index 000000000..ff5b71bd3 --- /dev/null +++ b/packages/ui/themes/neutral.css @@ -0,0 +1,59 @@ +.theme-neutral { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.225 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.16 0.005 285.823); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.274 0.006 286.033); + --input: oklch(0.274 0.006 286.033); + --ring: oklch(0.442 0.017 285.786); + --success: oklch(0.62 0.194 149.214); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.75 0.15 85); + --warning-foreground: oklch(0.21 0.006 285.885); + + --font-sans: "Inter", system-ui, sans-serif; + --font-mono: "Geist Mono", "SF Mono", Consolas, monospace; + --radius: 0.625rem; + + --code-bg: oklch(0.205 0.006 285.885); +} + +.theme-neutral.light { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.871 0.006 286.286); + --success: oklch(0.62 0.194 149.214); + --success-foreground: oklch(0.141 0.005 285.823); + --warning: oklch(0.55 0.18 85); + --warning-foreground: oklch(0.141 0.005 285.823); + + --code-bg: oklch(0.967 0.001 286.375); +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 26c1da740..e7b82dfea 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -215,6 +215,7 @@ export type { export type { AgentJobInfo, AgentJobEvent, + AgentJobLogs, AgentJobStatus, AgentCapability, AgentCapabilities, diff --git a/packages/ui/utils/api.test.ts b/packages/ui/utils/api.test.ts new file mode 100644 index 000000000..2544a36f6 --- /dev/null +++ b/packages/ui/utils/api.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiPath, getApiBase, getApiOriginAndBase } from "./api"; + +const originalWindow = globalThis.window; + +function setWindow(value: Partial) { + Object.defineProperty(globalThis, "window", { + value, + configurable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + }); +}); + +describe("api base helpers", () => { + test("defaults to root API base", () => { + setWindow({}); + expect(getApiBase()).toBe("/api"); + expect(apiPath("/plan")).toBe("/api/plan"); + expect(apiPath("/api/plan")).toBe("/api/plan"); + }); + + test("uses daemon-injected API base", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/s1/api" }); + expect(getApiBase()).toBe("/s/s1/api"); + expect(apiPath("/plan")).toBe("/s/s1/api/plan"); + expect(apiPath("plan")).toBe("/s/s1/api/plan"); + expect(apiPath("/api/plan")).toBe("/s/s1/api/plan"); + expect(apiPath("/api/")).toBe("/s/s1/api"); + }); + + test("trims trailing slash from injected API base", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/s1/api/" }); + expect(apiPath("/upload")).toBe("/s/s1/api/upload"); + }); + + test("builds absolute origin plus API base for agent instructions", () => { + setWindow({ + __PLANNOTATOR_API_BASE__: "/s/s1/api", + location: { origin: "http://localhost:1234" } as Location, + }); + expect(getApiOriginAndBase()).toBe("http://localhost:1234/s/s1/api"); + }); +}); diff --git a/packages/ui/utils/api.ts b/packages/ui/utils/api.ts new file mode 100644 index 000000000..c2d561a52 --- /dev/null +++ b/packages/ui/utils/api.ts @@ -0,0 +1,50 @@ +declare global { + interface Window { + __PLANNOTATOR_API_BASE__?: string; + } +} + +let globalFetchBase: string | undefined; + +export function setGlobalFetchBase(base: string | undefined): void { + globalFetchBase = base; +} + +function normalizeBase(base: string | undefined): string { + if (!base) return "/api"; + const trimmed = base.trim(); + if (!trimmed) return "/api"; + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} + +function normalizePath(path: string): string { + if (!path) return ""; + const prefixed = path.startsWith("/") ? path : `/${path}`; + return prefixed.length > 1 && prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed; +} + +export function getApiBase(): string { + if (typeof window === "undefined") return "/api"; + const sessionBase = window.__PLANNOTATOR_API_BASE__; + if (sessionBase) return normalizeBase(sessionBase); + if (globalFetchBase) return normalizeBase(globalFetchBase); + return "/api"; +} + +export function apiPath(path: string): string { + const normalized = normalizePath(path); + if (normalized === "/api") return getApiBase(); + if (normalized.startsWith("/api/")) { + return `${getApiBase()}${normalized.slice("/api".length)}`; + } + return `${getApiBase()}${normalized}`; +} + +export function apiFetch(input: string, init?: RequestInit): Promise { + return fetch(apiPath(input), init); +} + +export function getApiOriginAndBase(): string { + if (typeof window === "undefined") return "/api"; + return `${window.location.origin}${getApiBase()}`; +} diff --git a/packages/ui/utils/bear.test.ts b/packages/ui/utils/bear.test.ts deleted file mode 100644 index 615ae75e6..000000000 --- a/packages/ui/utils/bear.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { normalizeTags } from "./bear"; - -describe("normalizeTags", () => { - test("basic comma-separated tags", () => { - expect(normalizeTags("plan, work")).toBe("plan, work"); - }); - - test("strips # prefix", () => { - expect(normalizeTags("#plan, ##work")).toBe("plan, work"); - }); - - test("lowercases", () => { - expect(normalizeTags("Plan, WORK")).toBe("plan, work"); - }); - - test("replaces spaces with hyphens", () => { - expect(normalizeTags("my plan, some work")).toBe("my-plan, some-work"); - }); - - test("preserves slashes for Bear nested tags", () => { - expect(normalizeTags("plannotator/plans")).toBe("plannotator/plans"); - }); - - test("preserves deep nested tags", () => { - expect(normalizeTags("work/projects/frontend")).toBe("work/projects/frontend"); - }); - - test("mixed nested and flat tags", () => { - expect(normalizeTags("plannotator/plans, work, code/review")).toBe("plannotator/plans, work, code/review"); - }); - - test("collapses consecutive slashes", () => { - expect(normalizeTags("work//plans")).toBe("work/plans"); - }); - - test("strips leading/trailing slashes", () => { - expect(normalizeTags("/work/plans/")).toBe("work/plans"); - }); - - test("filters empty segments", () => { - expect(normalizeTags(",, plan")).toBe("plan"); - }); -}); diff --git a/packages/ui/utils/bear.ts b/packages/ui/utils/bear.ts deleted file mode 100644 index e7cb5b2a8..000000000 --- a/packages/ui/utils/bear.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Bear Notes Integration Utility - * - * Manages settings for auto-saving plans to Bear. - * Uses x-callback-url protocol - no vault detection needed. - */ - -import { storage } from './storage'; - -const STORAGE_KEY_ENABLED = 'plannotator-bear-enabled'; -const STORAGE_KEY_CUSTOM_TAGS = 'plannotator-bear-custom-tags'; -const STORAGE_KEY_TAG_POSITION = 'plannotator-bear-tag-position'; -const STORAGE_KEY_AUTOSAVE = 'plannotator-bear-autosave'; - -export type TagPosition = 'prepend' | 'append'; - -/** - * Bear integration settings - */ -export interface BearSettings { - enabled: boolean; - customTags: string; - tagPosition: TagPosition; - autoSave: boolean; -} - -/** - * Get current Bear settings from storage - */ -export function getBearSettings(): BearSettings { - return { - enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true', - customTags: storage.getItem(STORAGE_KEY_CUSTOM_TAGS) ?? '', - tagPosition: (storage.getItem(STORAGE_KEY_TAG_POSITION) as TagPosition) || 'append', - autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true', - }; -} - -/** - * Save Bear settings to storage - */ -export function saveBearSettings(settings: BearSettings): void { - storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled)); - storage.setItem(STORAGE_KEY_CUSTOM_TAGS, settings.customTags); - storage.setItem(STORAGE_KEY_TAG_POSITION, settings.tagPosition); - storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave)); -} - -/** - * Normalize raw tag input to clean kebab-case, comma-separated string. - * Strips # prefixes, lowercases, replaces spaces with hyphens, removes invalid chars. - */ -export function normalizeTags(raw: string): string { - return raw - .split(',') - .map(t => t.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-\/]/g, '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '')) - .filter(Boolean) - .join(', '); -} diff --git a/packages/ui/utils/daemonHub.test.ts b/packages/ui/utils/daemonHub.test.ts new file mode 100644 index 000000000..54d509d47 --- /dev/null +++ b/packages/ui/utils/daemonHub.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { UiDaemonHubClient, type WebSocketLike } from "./daemonHub"; + +const originalWindow = globalThis.window; +const originalEventSource = (globalThis as { EventSource?: unknown }).EventSource; +const originalWebSocket = (globalThis as { WebSocket?: unknown }).WebSocket; + +class FakeWebSocket implements WebSocketLike { + onopen: WebSocketLike["onopen"] = null; + onmessage: WebSocketLike["onmessage"] = null; + onclose: WebSocketLike["onclose"] = null; + onerror: WebSocketLike["onerror"] = null; + readyState = 0; + sent: string[] = []; + closed = false; + + send(data: string): void { + this.sent.push(data); + } + + emit(payload: unknown): void { + this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent); + } + + open(): void { + this.readyState = 1; + this.onopen?.(new Event("open")); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.onclose?.(new CloseEvent("close")); + } + + error(): void { + this.readyState = 3; + this.onerror?.(new Event("error")); + } +} + +function setWindow(value: Partial) { + Object.defineProperty(globalThis, "window", { + value, + configurable: true, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + }); + Object.defineProperty(globalThis, "EventSource", { + value: originalEventSource, + configurable: true, + }); + Object.defineProperty(globalThis, "WebSocket", { + value: originalWebSocket, + configurable: true, + }); +}); + +describe("daemon WebSocket hub client", () => { + test("uses one WebSocket for external annotation and agent job subscriptions", () => { + setWindow({ + location: { + href: "http://localhost:19432/s/session-one", + pathname: "/s/session-one", + } as Location, + }); + Object.defineProperty(globalThis, "EventSource", { + value: class { + constructor() { + throw new Error("EventSource should not be constructed."); + } + }, + configurable: true, + }); + + const sockets: FakeWebSocket[] = []; + const client = new UiDaemonHubClient((url) => { + expect(url).toBe("ws://localhost:19432/daemon/ws"); + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + const externalEvents: string[] = []; + const jobEvents: string[] = []; + + client.subscribe({ family: "external-annotations", sessionId: "session-one" }, (message) => { + externalEvents.push(message.type); + }); + client.subscribe({ family: "agent-jobs", sessionId: "session-one" }, (message) => { + jobEvents.push(message.type); + }); + + expect(sockets).toHaveLength(1); + sockets[0].open(); + expect(JSON.parse(sockets[0].sent[0])).toEqual({ + type: "subscribe", + scopes: [ + { family: "external-annotations", sessionId: "session-one" }, + { family: "agent-jobs", sessionId: "session-one" }, + ], + }); + + sockets[0].emit({ + type: "snapshot", + at: "2026-01-01T00:00:00.000Z", + scope: { family: "external-annotations", sessionId: "session-one" }, + payload: { annotations: [] }, + }); + sockets[0].emit({ + type: "event", + at: "2026-01-01T00:00:01.000Z", + scope: { family: "agent-jobs", sessionId: "session-one" }, + payload: { type: "job-started" }, + }); + + expect(externalEvents).toEqual(["snapshot"]); + expect(jobEvents).toEqual(["event"]); + }); + + test("does not send duplicate subscribe frames for an already-active scope", () => { + const sockets: FakeWebSocket[] = []; + const client = new UiDaemonHubClient(() => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + + client.subscribe({ family: "agent-jobs", sessionId: "session-one" }, () => {}); + sockets[0].open(); + client.subscribe({ family: "agent-jobs", sessionId: "session-one" }, () => {}); + + expect(sockets[0].sent.map((message) => JSON.parse(message))).toEqual([ + { + type: "subscribe", + scopes: [{ family: "agent-jobs", sessionId: "session-one" }], + }, + ]); + }); + + test("reconnects and resubscribes active session scopes", async () => { + setWindow({ + location: { + href: "http://localhost:19432/s/session-one", + pathname: "/s/session-one", + } as Location, + }); + const sockets: FakeWebSocket[] = []; + const client = new UiDaemonHubClient(() => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + + client.subscribe({ family: "external-annotations", sessionId: "session-one" }, () => {}); + sockets[0].open(); + sockets[0].close(); + + await sleep(550); + expect(sockets).toHaveLength(2); + sockets[1].open(); + expect(JSON.parse(sockets[1].sent[0])).toEqual({ + type: "subscribe", + scopes: [{ family: "external-annotations", sessionId: "session-one" }], + }); + }); + + test("does not let a stale close event clear a replacement socket", async () => { + setWindow({ + location: { + href: "http://localhost:19432/s/session-one", + pathname: "/s/session-one", + } as Location, + }); + const sockets: FakeWebSocket[] = []; + const client = new UiDaemonHubClient(() => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + + const unsubscribe = client.subscribe( + { family: "external-annotations", sessionId: "session-one" }, + () => {}, + ); + + sockets[0].error(); + await sleep(550); + expect(sockets).toHaveLength(2); + + sockets[0].close(); + sockets[1].open(); + + expect(JSON.parse(sockets[1].sent[0])).toEqual({ + type: "subscribe", + scopes: [{ family: "external-annotations", sessionId: "session-one" }], + }); + unsubscribe(); + }); + + test("reports connection state transitions for fallback polling", () => { + const sockets: FakeWebSocket[] = []; + const states: string[] = []; + const client = new UiDaemonHubClient(() => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + + const unsubscribe = client.subscribe( + { family: "external-annotations", sessionId: "session-one" }, + () => {}, + (state) => states.push(state), + ); + + expect(states).toEqual(["connecting"]); + sockets[0].open(); + sockets[0].close(); + expect(states).toEqual(["connecting", "open", "closed"]); + unsubscribe(); + }); + + test("treats daemon error frames as terminal fallback without reconnecting", async () => { + const sockets: FakeWebSocket[] = []; + const states: string[] = []; + const client = new UiDaemonHubClient(() => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket; + }); + + const unsubscribe = client.subscribe( + { family: "agent-jobs", sessionId: "session-one" }, + () => {}, + (state) => states.push(state), + ); + + sockets[0].open(); + sockets[0].emit({ + type: "error", + code: "session-not-found", + message: "No agent-jobs snapshot provider for session session-one.", + }); + + expect(states).toEqual(["connecting", "open", "closed"]); + expect(sockets[0].closed).toBe(true); + await sleep(550); + expect(sockets).toHaveLength(1); + unsubscribe(); + }); + + test("reports unavailable when the runtime has no WebSocket implementation", () => { + Object.defineProperty(globalThis, "WebSocket", { + value: undefined, + configurable: true, + }); + const states: string[] = []; + const client = new UiDaemonHubClient(); + + const unsubscribe = client.subscribe( + { family: "external-annotations", sessionId: "session-one" }, + () => {}, + (state) => states.push(state), + ); + + expect(states).toEqual(["unavailable"]); + unsubscribe(); + }); +}); diff --git a/packages/ui/utils/daemonHub.ts b/packages/ui/utils/daemonHub.ts new file mode 100644 index 000000000..a339135b6 --- /dev/null +++ b/packages/ui/utils/daemonHub.ts @@ -0,0 +1,185 @@ +import type { + DaemonEventFamily, + DaemonWebSocketFactory, + DaemonWebSocketLike, + DaemonWebSocketScope, + DaemonWebSocketServerMessage, +} from "@plannotator/shared/daemon-protocol"; +import { parseDaemonWebSocketServerMessageText } from "@plannotator/shared/daemon-protocol"; + +type ScopeSubscriber = (message: DaemonWebSocketServerMessage) => void; +export type DaemonHubConnectionState = "connecting" | "open" | "closed" | "unavailable"; +type ConnectionStateSubscriber = (state: DaemonHubConnectionState) => void; + +export type WebSocketLike = DaemonWebSocketLike; +export type WebSocketFactory = DaemonWebSocketFactory; + +const OPEN = 1; +const RECONNECT_MS = 500; + +function socketUrl(): string { + const url = new URL( + "/daemon/ws", + typeof window === "undefined" ? "http://localhost" : window.location.href, + ); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function scopeKey(scope: DaemonWebSocketScope): string { + return `${scope.family}:${scope.sessionId ?? ""}`; +} + +function currentSessionId(): string | undefined { + if (typeof window === "undefined") return undefined; + const match = window.location.pathname.match(/^\/s\/([^/]+)/); + return match ? decodeURIComponent(match[1]) : undefined; +} + +function defaultWebSocketFactory(): WebSocketFactory | undefined { + if (typeof WebSocket === "undefined") return undefined; + return (url) => new WebSocket(url) as WebSocketLike; +} + +export class UiDaemonHubClient { + private socket?: WebSocketLike; + private reconnectTimer: ReturnType | undefined; + private subscribers = new Map>(); + private stateSubscribers = new Set(); + private scopes = new Map(); + private connectionState: DaemonHubConnectionState; + + constructor(private readonly webSocketFactory: WebSocketFactory | undefined = defaultWebSocketFactory()) { + this.connectionState = webSocketFactory ? "closed" : "unavailable"; + } + + subscribe( + scope: DaemonWebSocketScope, + subscriber: ScopeSubscriber, + onState?: ConnectionStateSubscriber, + ): () => void { + const key = scopeKey(scope); + const isNewScope = !this.subscribers.has(key); + this.scopes.set(key, scope); + const subscribers = this.subscribers.get(key) ?? new Set(); + subscribers.add(subscriber); + this.subscribers.set(key, subscribers); + if (onState) this.stateSubscribers.add(onState); + const previousState = this.connectionState; + this.connect(); + if (onState && this.connectionState === previousState) onState(this.connectionState); + // If the socket is already open, subscribe immediately; new sockets send + // all active scopes from onopen once the connection is actually writable. + if (isNewScope) this.sendSubscribe([scope]); + return () => { + subscribers.delete(subscriber); + if (onState) this.stateSubscribers.delete(onState); + if (subscribers.size === 0) { + this.subscribers.delete(key); + this.scopes.delete(key); + this.send({ type: "unsubscribe", scopes: [scope] }); + } + if (this.subscribers.size === 0) this.close(); + }; + } + + private connect(): void { + if (!this.webSocketFactory) { + this.setConnectionState("unavailable"); + return; + } + if (this.socket?.readyState === OPEN) { + this.setConnectionState("open"); + return; + } + if (this.socket) { + this.setConnectionState("connecting"); + return; + } + this.setConnectionState("connecting"); + const socket = this.webSocketFactory(socketUrl()); + this.socket = socket; + socket.onopen = () => { + if (this.socket !== socket) return; + this.setConnectionState("open"); + this.sendSubscribe(Array.from(this.scopes.values())); + }; + socket.onmessage = (event) => { + if (this.socket !== socket) return; + const message = parseDaemonWebSocketServerMessageText(event.data); + if (!message) return; + if (message.type === "error") { + // Server errors are not scoped/correlated for subscriptions yet. Close + // without reconnecting so deleted sessions fall back to polling instead + // of re-subscribing to the same permanent failure forever. + this.socket = undefined; + socket.close(); + this.setConnectionState("closed"); + return; + } + if (message.type !== "snapshot" && message.type !== "event") return; + const subscribers = this.subscribers.get(scopeKey(message.scope)); + if (!subscribers) return; + for (const subscriber of subscribers) subscriber(message); + }; + socket.onerror = () => { + if (this.socket !== socket) return; + this.socket = undefined; + socket.close(); + this.setConnectionState("closed"); + this.scheduleReconnect(); + }; + socket.onclose = () => { + if (this.socket !== socket) return; + this.socket = undefined; + this.setConnectionState("closed"); + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer || this.subscribers.size === 0) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, RECONNECT_MS); + } + + private sendSubscribe(scopes: DaemonWebSocketScope[]): void { + if (scopes.length === 0) return; + this.send({ type: "subscribe", scopes }); + } + + private send(message: { type: "subscribe" | "unsubscribe"; scopes: DaemonWebSocketScope[] }): void { + if (this.socket?.readyState !== OPEN) return; + this.socket.send(JSON.stringify(message)); + } + + private close(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.socket?.close(); + this.socket = undefined; + this.setConnectionState(this.webSocketFactory ? "closed" : "unavailable"); + } + + private setConnectionState(state: DaemonHubConnectionState): void { + if (this.connectionState === state) return; + this.connectionState = state; + for (const subscriber of this.stateSubscribers) subscriber(state); + } +} + +const client = new UiDaemonHubClient(); + +export function subscribeToDaemonSessionFamily( + family: Exclude, + subscriber: ScopeSubscriber, + onState?: ConnectionStateSubscriber, +): (() => void) | undefined { + const sessionId = currentSessionId(); + if (!sessionId) return undefined; + return client.subscribe({ family, sessionId }, subscriber, onState); +} diff --git a/packages/ui/utils/defaultNotesApp.ts b/packages/ui/utils/defaultNotesApp.ts deleted file mode 100644 index aa944bc73..000000000 --- a/packages/ui/utils/defaultNotesApp.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Default Notes App Preference - * - * Stores the user's preferred notes app for the Cmd/Ctrl+S shortcut. - * Uses cookies (not localStorage) because each hook invocation runs on a random port. - */ - -import { storage } from './storage'; - -const STORAGE_KEY = 'plannotator-default-notes-app'; - -export type DefaultNotesApp = 'obsidian' | 'bear' | 'octarine' | 'download' | 'ask'; - -export function getDefaultNotesApp(): DefaultNotesApp { - return (storage.getItem(STORAGE_KEY) as DefaultNotesApp) || 'ask'; -} - -export function saveDefaultNotesApp(app: DefaultNotesApp): void { - storage.setItem(STORAGE_KEY, app); -} diff --git a/packages/ui/utils/identity.ts b/packages/ui/utils/identity.ts index 8b73f7504..b595520c2 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -17,7 +17,7 @@ import { generateIdentity } from './generateIdentity'; * Get current identity from ConfigStore. */ export function getIdentity(): string { - return configStore.get('displayName'); + return configStore.getState().get('displayName'); } /** @@ -27,7 +27,7 @@ export function getIdentity(): string { export function setCustomIdentity(name: string): string { const trimmed = name.trim(); if (!trimmed) return getIdentity(); // reject empty - configStore.set('displayName', trimmed); + configStore.getState().set('displayName', trimmed); return trimmed; } @@ -37,7 +37,7 @@ export function setCustomIdentity(name: string): string { */ export function regenerateIdentity(): string { const identity = generateIdentity(); - configStore.set('displayName', identity); + configStore.getState().set('displayName', identity); return identity; } @@ -46,5 +46,5 @@ export function regenerateIdentity(): string { */ export function isCurrentUser(author: string | undefined): boolean { if (!author) return false; - return author === configStore.get('displayName'); + return author === configStore.getState().get('displayName'); } diff --git a/packages/ui/utils/obsidian.ts b/packages/ui/utils/obsidian.ts deleted file mode 100644 index 19420d25b..000000000 --- a/packages/ui/utils/obsidian.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Obsidian Integration Utility - * - * Manages settings for auto-saving plans to Obsidian vaults. - * Settings are stored in cookies (like other settings) so they persist - * across different ports used by the hook server. - */ - -import { storage } from './storage'; - -// Storage keys -const STORAGE_KEY_ENABLED = 'plannotator-obsidian-enabled'; -const STORAGE_KEY_VAULT = 'plannotator-obsidian-vault'; -const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder'; -const STORAGE_KEY_CUSTOM_PATH = 'plannotator-obsidian-custom-path'; -const STORAGE_KEY_FILENAME_FORMAT = 'plannotator-obsidian-filename-format'; -const STORAGE_KEY_VAULT_BROWSER = 'plannotator-obsidian-vault-browser'; -const STORAGE_KEY_AUTOSAVE = 'plannotator-obsidian-autosave'; -const STORAGE_KEY_FILENAME_SEPARATOR = 'plannotator-obsidian-filename-separator'; - -// Sentinel value for custom path selection -export const CUSTOM_PATH_SENTINEL = '__custom__'; - -// Default folder name in the vault -const DEFAULT_FOLDER = 'plannotator'; - -// Default filename format — matches the original hardcoded behavior -export const DEFAULT_FILENAME_FORMAT = '{title} - {Mon} {D}, {YYYY} {h}-{mm}{ampm}'; - -/** - * Obsidian integration settings - */ -export type FilenameSeparator = 'space' | 'dash' | 'underscore'; - -export interface ObsidianSettings { - enabled: boolean; - vaultPath: string; // Selected vault path OR '__custom__' sentinel - folder: string; - customPath?: string; // User-entered path when vaultPath === '__custom__' - filenameFormat?: string; // Custom filename format (e.g. '{YYYY}-{MM}-{DD} - {title}') - filenameSeparator: FilenameSeparator; // Replace spaces in filename with dash/underscore - autoSave: boolean; // Auto-save to Obsidian on plan arrival - vaultBrowserEnabled: boolean; // Show vault file browser in sidebar -} - -/** - * Get current Obsidian settings from storage - */ -export function getObsidianSettings(): ObsidianSettings { - return { - enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true', - vaultPath: storage.getItem(STORAGE_KEY_VAULT) || '', - folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER, - customPath: storage.getItem(STORAGE_KEY_CUSTOM_PATH) || undefined, - filenameFormat: storage.getItem(STORAGE_KEY_FILENAME_FORMAT) || undefined, - filenameSeparator: (storage.getItem(STORAGE_KEY_FILENAME_SEPARATOR) as FilenameSeparator) || 'space', - autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true', - vaultBrowserEnabled: storage.getItem(STORAGE_KEY_VAULT_BROWSER) === 'true', - }; -} - -/** - * Save Obsidian settings to storage - */ -export function saveObsidianSettings(settings: ObsidianSettings): void { - storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled)); - storage.setItem(STORAGE_KEY_VAULT, settings.vaultPath); - storage.setItem(STORAGE_KEY_FOLDER, settings.folder); - storage.setItem(STORAGE_KEY_CUSTOM_PATH, settings.customPath || ''); - storage.setItem(STORAGE_KEY_FILENAME_FORMAT, settings.filenameFormat || ''); - storage.setItem(STORAGE_KEY_FILENAME_SEPARATOR, settings.filenameSeparator || 'space'); - storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave)); - storage.setItem(STORAGE_KEY_VAULT_BROWSER, String(settings.vaultBrowserEnabled)); -} - -/** - * Get the effective vault path, resolving custom path if selected - */ -export function getEffectiveVaultPath(settings: ObsidianSettings): string { - if (settings.vaultPath === CUSTOM_PATH_SENTINEL) { - return settings.customPath || ''; - } - return settings.vaultPath; -} - -/** - * Check if Obsidian integration is properly configured - */ -export function isObsidianConfigured(): boolean { - const settings = getObsidianSettings(); - const effectivePath = getEffectiveVaultPath(settings); - return settings.enabled && effectivePath.trim().length > 0; -} - -/** - * Check if the vault browser sidebar tab should be shown - */ -export function isVaultBrowserEnabled(): boolean { - const settings = getObsidianSettings(); - const effectivePath = getEffectiveVaultPath(settings); - return settings.enabled && settings.vaultBrowserEnabled && effectivePath.trim().length > 0; -} - -/** - * Extract tags from markdown content using simple heuristics - * - * Extracts: - * - Words from the first H1 title (excluding common words) - * - Code fence languages (```typescript, ```sql, etc.) - * - Always includes "plan" as base tag - * - * @param markdown - The markdown content to extract tags from - * @returns Array of lowercase tag strings (max 6) - */ -export function extractTags(markdown: string): string[] { - const tags = new Set(['plannotator']); - - // Common words to exclude from title extraction - const stopWords = new Set([ - 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', - 'plan', 'implementation', 'overview', 'phase', 'step', 'steps', - ]); - - // 1. Extract from first H1 title - // Matches: "# Title" or "# Implementation Plan: Title" or "# Plan: Title" - const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im); - if (h1Match) { - const titleWords = h1Match[1] - .toLowerCase() - .replace(/[^\w\s-]/g, ' ') // Remove special chars except hyphens - .split(/\s+/) - .filter(word => word.length > 2 && !stopWords.has(word)); - - // Add first 3 meaningful words from title - titleWords.slice(0, 3).forEach(word => tags.add(word)); - } - - // 2. Extract code fence languages - // Matches: ```typescript, ```sql, ```rust, etc. - const langMatches = markdown.matchAll(/```(\w+)/g); - const seenLangs = new Set(); - - for (const [, lang] of langMatches) { - const normalizedLang = lang.toLowerCase(); - // Skip generic/config languages and duplicates - if (!seenLangs.has(normalizedLang) && - !['json', 'yaml', 'yml', 'text', 'txt', 'markdown', 'md'].includes(normalizedLang)) { - seenLangs.add(normalizedLang); - tags.add(normalizedLang); - } - } - - // Return max 6 tags - return Array.from(tags).slice(0, 6); -} - -/** - * Generate YAML frontmatter for an Obsidian note - * - * @param tags - Array of tags to include - * @returns Frontmatter string including opening and closing --- - */ -export function generateFrontmatter(tags: string[]): string { - const now = new Date().toISOString(); - const tagList = tags.map(t => t.toLowerCase()).join(', '); - - return `--- -created: ${now} -source: plannotator -tags: [${tagList}] ----`; -} - -/** - * Generate a filename for the plan note - * Format: YYYY-MM-DD-HHmm.md (e.g., 2026-01-02-1430.md) - * - * @returns Filename string - */ -export function generateFilename(): string { - const now = new Date(); - const timestamp = now.toISOString() - .slice(0, 16) // "2026-01-02T14:30" - .replace('T', '-') // "2026-01-02-14:30" - .replace(/:/g, ''); // "2026-01-02-1430" - - return `${timestamp}.md`; -} - -/** - * Prepare the full note content with frontmatter - * - * @param markdown - The plan markdown content - * @returns Full note content with frontmatter prepended - */ -export function prepareNoteContent(markdown: string): string { - const tags = extractTags(markdown); - const frontmatter = generateFrontmatter(tags); - - return `${frontmatter}\n\n${markdown}`; -} diff --git a/packages/ui/utils/octarine.ts b/packages/ui/utils/octarine.ts deleted file mode 100644 index fa3f5a99b..000000000 --- a/packages/ui/utils/octarine.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Octarine Notes Integration Utility - * - * Manages settings for auto-saving plans to Octarine. - * Uses octarine:// URI scheme — no vault detection needed. - */ - -import { storage } from './storage'; - -const STORAGE_KEY_ENABLED = 'plannotator-octarine-enabled'; -const STORAGE_KEY_WORKSPACE = 'plannotator-octarine-workspace'; -const STORAGE_KEY_FOLDER = 'plannotator-octarine-folder'; -const STORAGE_KEY_AUTOSAVE = 'plannotator-octarine-autosave'; - -/** - * Octarine integration settings - */ -export interface OctarineSettings { - enabled: boolean; - workspace: string; - folder: string; - autoSave: boolean; -} - -/** - * Get current Octarine settings from storage - */ -export function getOctarineSettings(): OctarineSettings { - return { - enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true', - workspace: storage.getItem(STORAGE_KEY_WORKSPACE) ?? '', - folder: storage.getItem(STORAGE_KEY_FOLDER) || 'plannotator', - autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true', - }; -} - -/** - * Save Octarine settings to storage - */ -export function saveOctarineSettings(settings: OctarineSettings): void { - storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled)); - storage.setItem(STORAGE_KEY_WORKSPACE, settings.workspace); - storage.setItem(STORAGE_KEY_FOLDER, settings.folder); - storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave)); -} - -/** - * Check if Octarine integration is properly configured - */ -export function isOctarineConfigured(): boolean { - const settings = getOctarineSettings(); - return settings.enabled && settings.workspace.trim().length > 0; -} diff --git a/packages/ui/utils/planAgentInstructions.test.ts b/packages/ui/utils/planAgentInstructions.test.ts new file mode 100644 index 000000000..de8dfcb63 --- /dev/null +++ b/packages/ui/utils/planAgentInstructions.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { buildPlanAgentInstructions } from "./planAgentInstructions"; + +describe("buildPlanAgentInstructions", () => { + test("uses the provided API base instead of assuming root /api routes", () => { + const instructions = buildPlanAgentInstructions("http://localhost:1234/s/s1/api"); + + expect(instructions).toContain("curl -s http://localhost:1234/s/s1/api/plan"); + expect(instructions).toContain("http://localhost:1234/s/s1/api/external-annotations"); + expect(instructions).not.toContain("/s/s1/api/api/"); + }); +}); diff --git a/packages/ui/utils/planAgentInstructions.ts b/packages/ui/utils/planAgentInstructions.ts index 25987c9e0..5b72986c2 100644 --- a/packages/ui/utils/planAgentInstructions.ts +++ b/packages/ui/utils/planAgentInstructions.ts @@ -1,7 +1,7 @@ /** * Builds the clipboard payload that teaches an external agent (Claude Code, * Codex, custom scripts, etc.) how to post annotations into a live Plannotator - * **plan-review** session via the /api/external-annotations HTTP API. + * **plan-review** session via the external-annotations HTTP API. * * Plan mode and code-review mode have different annotation shapes (plan uses * `originalText` for inline highlighting; review uses `filePath` + line ranges @@ -12,11 +12,11 @@ * it top-to-bottom and start posting in 30 seconds. Edit freely — this file is * the single source of truth for the agent-facing contract surface. * - * The only dynamic value is `origin`, which is interpolated at click time from - * `window.location.origin` so the agent gets the correct base URL whether the - * server is running on a random local port or the fixed remote port (19432). + * The only dynamic value is `apiBase`, which is interpolated at click time from + * the runtime API base so the agent gets the correct session URL whether the + * UI is served from a standalone root server or the long-running daemon. */ -export function buildPlanAgentInstructions(origin: string): string { +export function buildPlanAgentInstructions(apiBase: string): string { return `# Plannotator — External Annotations You can submit review feedback on the user's current plan-review session by POSTing annotations to a small HTTP API. The user will see them immediately — inline highlights on the plan and entries in a sidebar — and can accept, edit, or delete them. @@ -24,7 +24,7 @@ You can submit review feedback on the user's current plan-review session by POST This is one-way submission. Any tool can post: linters, agents, scripts. The user does not see who you are unless you tell them via \`text\` or \`author\`. ## Base URL -${origin} +${apiBase} All endpoints below are relative to that base. No authentication. @@ -38,7 +38,7 @@ There is no "send" or "done" step — each POST is live the moment it lands. ## Reading the plan \`\`\`sh -curl -s ${origin}/api/plan | jq -r .plan +curl -s ${apiBase}/plan | jq -r .plan \`\`\` **Line numbers do not apply and cannot be referenced.** The renderer pins your comments to the plan by matching the \`originalText\` field as a verbatim substring of the rendered text. Quote the exact phrase, never say "line 12." @@ -53,7 +53,7 @@ You have exactly two shapes to choose from: ## Posting an inline comment \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "source": "claude-code", @@ -68,7 +68,7 @@ curl -s ${origin}/api/external-annotations \\ ## Posting a global comment \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "source": "claude-code", @@ -92,7 +92,7 @@ Both endpoints return \`201 {"ids": [""]}\` on success, \`400 {"error": ". ## Batching \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "annotations": [ @@ -109,16 +109,16 @@ Batches are atomic: if any item fails validation, the whole batch is rejected wi \`\`\`sh # List everything (yours and others') -curl -s ${origin}/api/external-annotations | jq +curl -s ${apiBase}/external-annotations | jq # Delete one annotation by id — works on any source, including the user's -curl -s -X DELETE "${origin}/api/external-annotations?id=" +curl -s -X DELETE "${apiBase}/external-annotations?id=" # Delete all annotations from one source — the standard cleanup before reposting -curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" +curl -s -X DELETE "${apiBase}/external-annotations?source=claude-code" # Delete everything in the session -curl -s -X DELETE ${origin}/api/external-annotations +curl -s -X DELETE ${apiBase}/external-annotations \`\`\` You have full delete authority. Use it responsibly. @@ -128,14 +128,14 @@ You have full delete authority. Use it responsibly. If you re-run on the same session, your previous annotations are still there. POSTing again will create duplicates. Standard pattern: \`\`\`sh -curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" -curl -s ${origin}/api/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }' +curl -s -X DELETE "${apiBase}/external-annotations?source=claude-code" +curl -s ${apiBase}/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }' \`\`\` This is why \`source\` matters. Pick a stable identifier and stick with it. ## Notes -- The plan can change underneath you. If the user denies and resubmits, refetch \`/api/plan\` — your prior \`originalText\` substrings may no longer match. +- The plan can change underneath you. If the user denies and resubmits, refetch \`${apiBase}/plan\` — your prior \`originalText\` substrings may no longer match. - No idempotency. Posting the same annotation twice creates two entries. - This API is local to the user's machine. Treat it as a UI surface, not a public service. `; diff --git a/packages/ui/utils/themeRegistry.ts b/packages/ui/utils/themeRegistry.ts index 5658450b4..154657dab 100644 --- a/packages/ui/utils/themeRegistry.ts +++ b/packages/ui/utils/themeRegistry.ts @@ -544,4 +544,14 @@ export const BUILT_IN_THEMES: ThemeInfo[] = [ light: { primary: '#4d9375', secondary: '#191919', accent: '#6394bf', background: '#000000', foreground: '#dbd7ca' }, }, }, + { + id: 'neutral', + name: 'Neutral', + builtIn: true, + modeSupport: 'both', + colors: { + dark: { primary: 'oklch(0.985 0 0)', secondary: 'oklch(0.274 0.006 286.033)', accent: 'oklch(0.274 0.006 286.033)', background: 'oklch(0.141 0.005 285.823)', foreground: 'oklch(0.985 0 0)' }, + light: { primary: 'oklch(0.21 0.006 285.885)', secondary: 'oklch(0.967 0.001 286.375)', accent: 'oklch(0.967 0.001 286.375)', background: 'oklch(1 0 0)', foreground: 'oklch(0.141 0.005 285.823)' }, + }, + }, ]; diff --git a/scripts/dev-frontend.ts b/scripts/dev-frontend.ts new file mode 100644 index 000000000..dc08d73ed --- /dev/null +++ b/scripts/dev-frontend.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import path from "path"; + +const entry = "apps/hook/server/index.ts"; +const frontendDir = path.resolve(import.meta.dirname!, "../apps/frontend"); + +console.log("[dev] Stopping existing daemon..."); +await $`bun run ${entry} daemon stop`.quiet().nothrow(); + +console.log("[dev] Starting daemon from source..."); +const start = await $`bun run ${entry} daemon start`.quiet().nothrow(); +const output = start.stdout.toString().trim(); + +try { + const result = JSON.parse(output); + const url = result.status?.endpoint?.baseUrl ?? result.browserUrl ?? "unknown"; + console.log(`[dev] Daemon running at ${url}`); +} catch { + console.log("[dev] Daemon started"); +} + +console.log("[dev] Starting frontend dev server..."); +console.log("[dev] Press Ctrl+C to stop everything\n"); + +const frontend = Bun.spawn(["bun", "run", "dev"], { + cwd: frontendDir, + stdio: ["inherit", "inherit", "inherit"], +}); + +process.on("SIGINT", async () => { + frontend.kill(); + console.log("\n[dev] Stopping daemon..."); + await $`bun run ${entry} daemon stop`.quiet().nothrow(); + process.exit(0); +}); + +await frontend.exited; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a778fe955..55b2f3aa0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -230,11 +230,23 @@ if ($verifyAttestationResolved) { Write-Host "https://plannotator.ai/docs/getting-started/installation/#verifying-your-install" } +# Stop any running daemon before replacing the binary. On Windows the running +# exe is file-locked; on upgrades the old daemon would serve stale code. +if (Test-Path "$installDir\plannotator.exe") { + $stopProc = Start-Process -FilePath "$installDir\plannotator.exe" -ArgumentList "daemon","stop" -WindowStyle Hidden -PassThru -ErrorAction SilentlyContinue + if ($stopProc -and !$stopProc.WaitForExit(10000)) { + $stopProc.Kill() + } +} + Move-Item -Force $tmpFile "$installDir\plannotator.exe" Write-Host "" Write-Host "plannotator $latestTag installed to $installDir\plannotator.exe" +# Start a fresh daemon so hooks work immediately. +Start-Process -FilePath "$installDir\plannotator.exe" -ArgumentList "daemon","start" -WindowStyle Hidden -ErrorAction SilentlyContinue + # Add to PATH if not already there $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -notlike "*$installDir*") { @@ -476,7 +488,7 @@ Address the annotation feedback above. The user has reviewed your last message a Write-Host "Installed /plannotator-last command to $claudeCommandsDir\plannotator-last.md" # Install OpenCode slash command -$opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\commands" +$opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\command" New-Item -ItemType Directory -Force -Path $opencodeCommandsDir | Out-Null @" diff --git a/scripts/install.sh b/scripts/install.sh index 9dd510599..097664674 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -298,6 +298,11 @@ else fi # Remove old binary first (handles Windows .exe and locked file issues) +# Stop any running daemon before replacing the binary. On upgrades the old +# daemon would keep serving stale code from memory; on Windows the running +# exe would also be file-locked. Silent if no daemon is running. +"$INSTALL_DIR/plannotator" daemon stop >/dev/null 2>&1 || true + rm -f "$INSTALL_DIR/plannotator" "$INSTALL_DIR/plannotator.exe" 2>/dev/null || true mv "$tmp_file" "$INSTALL_DIR/plannotator" @@ -306,6 +311,9 @@ chmod +x "$INSTALL_DIR/plannotator" echo "" echo "plannotator ${latest_tag} installed to ${INSTALL_DIR}/plannotator" +# Start a fresh daemon so hooks work immediately. +"$INSTALL_DIR/plannotator" daemon start >/dev/null 2>&1 & + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo "" echo "${INSTALL_DIR} is not in your PATH. Add it with:" @@ -702,7 +710,7 @@ COMMAND_EOF echo "Installed /plannotator-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-last.md" # Install OpenCode slash command -OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands" +OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command" mkdir -p "$OPENCODE_COMMANDS_DIR" cat > "$OPENCODE_COMMANDS_DIR/plannotator-review.md" << 'COMMAND_EOF' diff --git a/scripts/smoke-pi-extension-ai-runtime.ts b/scripts/smoke-pi-extension-ai-runtime.ts deleted file mode 100644 index 6aea1462e..000000000 --- a/scripts/smoke-pi-extension-ai-runtime.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; - -import { createPiAIRuntime } from "../apps/pi-extension/server/ai-runtime.ts"; - -function writeText(path: string, content: string): void { - writeFileSync(path, content.replace(/\n/g, "\r\n"), "utf-8"); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function removeTempDirWithRetry(path: string): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < 20; attempt++) { - try { - rmSync(path, { recursive: true, force: true }); - return; - } catch (error) { - lastError = error; - await sleep(250); - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError)); -} - -async function main(): Promise { - if (process.platform !== "win32") { - console.log("Skipping Pi extension AI runtime smoke: Windows-only."); - return; - } - - const tempDir = mkdtempSync(join(tmpdir(), "plannotator-pi-ai-smoke-")); - const fakeBin = join(tempDir, "bin"); - const pathEnvKey = - Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? - "PATH"; - const originalPath = process.env[pathEnvKey] ?? ""; - - try { - mkdirSync(fakeBin, { recursive: true }); - writeText( - join(fakeBin, "where.cmd"), - `@echo off -if /I "%~1"=="pi" ( - echo %~dp0pi - echo %~dp0pi.cmd - exit /b 0 -) -exit /b 1 -`, - ); - writeText( - join(fakeBin, "pi"), - `extensionless npm shim placeholder -`, - ); - writeText( - join(fakeBin, "pi.cmd"), - `@echo off -node "%~dp0pi-rpc.cjs" %* -`, - ); - writeFileSync( - join(fakeBin, "pi-rpc.cjs"), - ` -const fs = require("node:fs"); -const readline = require("node:readline"); -const marker = require("node:path").join(__dirname, "spawned.txt"); -const lock = fs.openSync(require("node:path").join(__dirname, "child.lock"), "w"); - -fs.writeFileSync(marker, process.argv.slice(2).join(" "), "utf8"); -fs.writeSync(lock, String(process.pid)); - -if (process.argv[2] !== "--mode" || process.argv[3] !== "rpc") { - console.error("unexpected args:", process.argv.slice(2).join(" ")); - process.exit(2); -} - -const rl = readline.createInterface({ input: process.stdin }); -rl.on("line", (line) => { - if (!line.trim()) return; - const message = JSON.parse(line); - if (message.type === "get_available_models") { - process.stdout.write(JSON.stringify({ - type: "response", - id: message.id, - success: true, - data: { - models: [ - { provider: "fake", id: "windows-smoke", name: "Windows smoke" } - ] - } - }) + "\\n"); - return; - } - process.stdout.write(JSON.stringify({ - type: "response", - id: message.id, - success: true, - data: {} - }) + "\\n"); -}); - -setInterval(() => {}, 1000); -`, - "utf-8", - ); - - process.env[pathEnvKey] = `${fakeBin}${delimiter}${originalPath}`; - - const runtime = await createPiAIRuntime({ - cwd: tempDir, - getCwd: () => tempDir, - }); - if (!runtime) throw new Error("createPiAIRuntime returned null"); - - try { - const response = await runtime.endpoints["/api/ai/capabilities"]( - new Request("http://localhost/api/ai/capabilities"), - ); - if (!response.ok) { - throw new Error(`/api/ai/capabilities returned ${response.status}`); - } - - const body = (await response.json()) as { - providers?: Array<{ - id: string; - name: string; - models?: Array<{ id: string; label: string }>; - }>; - }; - const piProvider = body.providers?.find( - (provider) => provider.name === "pi-sdk", - ); - if (!piProvider) { - throw new Error(`pi-sdk provider missing: ${JSON.stringify(body)}`); - } - if ( - !piProvider.models?.some( - (model) => model.id === "fake/windows-smoke", - ) - ) { - throw new Error(`fake Pi model missing: ${JSON.stringify(body)}`); - } - - console.log("Pi extension AI runtime Windows shim smoke passed."); - } finally { - runtime.dispose(); - } - } finally { - process.env[pathEnvKey] = originalPath; - await removeTempDirWithRetry(tempDir); - } -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/test-plan-session.sh b/scripts/test-plan-session.sh new file mode 100755 index 000000000..0ae0c3848 --- /dev/null +++ b/scripts/test-plan-session.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Launch a test plan session against the running daemon. +# Usage: ./scripts/test-plan-session.sh + +set -euo pipefail + +DAEMON_JSON="$HOME/.plannotator/daemon.json" +if [ ! -f "$DAEMON_JSON" ]; then + echo "No daemon running. Start one with: plannotator daemon start" + exit 1 +fi + +PORT=$(python3 -c "import json; print(json.load(open('$DAEMON_JSON'))['port'])") +TOKEN=$(python3 -c "import json; print(json.load(open('$DAEMON_JSON'))['authToken'])") +BASE="http://localhost:$PORT" + +echo "Daemon at $BASE" +echo "Creating plan session..." + +curl -s -X POST "$BASE/daemon/sessions" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "request": { + "action": "plan", + "origin": "claude-code", + "cwd": "'"$(pwd)"'", + "plan": "# Refactor Authentication Module\n\n## Overview\n\nMigrate the authentication system from session-based to JWT tokens. This reduces server-side state and simplifies horizontal scaling.\n\n## Phase 1: Token Infrastructure\n\n- [ ] Create `JWTService` class with sign/verify methods\n- [ ] Add `RS256` key pair generation on first boot\n- [ ] Store public key in `~/.config/auth/public.pem`\n- [ ] Add token expiry configuration (default: 24h access, 7d refresh)\n\n## Phase 2: Migration\n\n- [ ] Replace `express-session` middleware with `authenticateJWT` middleware\n- [ ] Update `/api/login` to return `{ accessToken, refreshToken }`\n- [ ] Add `/api/refresh` endpoint for token rotation\n- [ ] Migrate session data to JWT claims:\n - `userId` → `sub` claim\n - `role` → custom `role` claim\n - `permissions` → custom `perms` claim (compressed)\n\n## Phase 3: Cleanup\n\n- [ ] Remove Redis session store dependency\n- [ ] Delete `SessionManager` class\n- [ ] Update API documentation\n- [ ] Add integration tests for token refresh flow\n\n## Risks\n\n> [!WARNING]\n> Token revocation requires a blocklist. Without it, compromised tokens remain valid until expiry.\n\n> [!NOTE]\n> Refresh token rotation mitigates this — each refresh invalidates the previous token.\n\n## Files to Modify\n\n| File | Change |\n|------|--------|\n| `src/auth/index.ts` | New JWT service |\n| `src/middleware/auth.ts` | Replace session check |\n| `src/routes/login.ts` | Return tokens |\n| `src/routes/refresh.ts` | New endpoint |\n| `src/config.ts` | Add JWT config |\n| `package.json` | Add jsonwebtoken, remove express-session |\n\n```typescript\n// src/auth/jwt-service.ts\nexport class JWTService {\n constructor(private readonly privateKey: string) {}\n\n sign(payload: TokenPayload): string {\n return jwt.sign(payload, this.privateKey, {\n algorithm: \"RS256\",\n expiresIn: \"24h\",\n });\n }\n}\n```" + } + }' | python3 -m json.tool + +echo "" +echo "Session created. Open the frontend to see it." diff --git a/tests/README.md b/tests/README.md index 005015e29..72aaac758 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,15 +35,6 @@ These scripts test integrations, releases, and provide utilities. Tests the installed `plannotator` binary to verify releases work correctly. -**Bulk plan testing (Obsidian integration):** - -```bash -./tests/manual/local/test-bulk-plans.sh # Iterate through ~/.claude/plans/ -``` - -Opens each `.md` file from `~/.claude/plans/` in Plannotator. Great for testing Obsidian integration with multiple -plans. - **OpenCode integration sandbox:** ```bash @@ -76,14 +67,6 @@ Tips: - The validated workflow is: run the script in one terminal, then point Playwright at the printed session URL from a second terminal. -**Obsidian utility:** - -```bash -./tests/manual/local/fix-vault-links.sh /path/to/vault/plannotator -``` - -Adds Obsidian backlinks (`[[Plannotator Plans]]`) to existing plan files in your vault. - ## SSH Remote Testing (`manual/ssh/`) Tests SSH session detection and port forwarding for remote development scenarios. diff --git a/tests/UI-TESTING.md b/tests/UI-TESTING.md index a9554068b..3a00de279 100644 --- a/tests/UI-TESTING.md +++ b/tests/UI-TESTING.md @@ -34,14 +34,12 @@ The project uses a monorepo structure: - **`packages/`** - Shared code - `ui/` - Reusable React components, hooks, utilities - - `server/` - Server implementation (plan/review servers) - - `editor/` - Plan review application logic - - `review-editor/` - Code review application logic + - `server/` - Server implementation (plan/review/annotate servers) - **`apps/`** - Deployable applications - `hook/` - Claude Code plugin (plan review) + - `frontend/` - Production frontend SPA (plan review, code review, annotate) - `opencode-plugin/` - OpenCode plugin - - `review/` - Standalone review app - `portal/` - Share portal (share.plannotator.ai) - `marketing/` - Marketing site (plannotator.ai) @@ -66,15 +64,10 @@ If successful, you'll see `apps/hook/dist/index.html` created. - Location: `packages/ui/components/` - Examples: `TableOfContents.tsx`, `AnnotationToolbar.tsx`, `Viewer.tsx` -**Plan editor** (plan review UI): +**Frontend SPA** (plan review, code review, annotate): -- Location: `packages/editor/App.tsx` -- Main application logic for plan review - -**Code review editor** (code review UI): - -- Location: `packages/review-editor/App.tsx` -- Main application logic for code review +- Location: `apps/frontend/src/` +- Main application for all session types **Utilities and hooks**: @@ -86,14 +79,10 @@ If successful, you'll see `apps/hook/dist/index.html` created. For rapid iteration, use development servers with hot reload: ```bash -# Plan review UI (most common) -bun run dev:hook +# Frontend SPA (plan review, code review, annotate) +bun run dev:frontend # Opens http://localhost:5173 -# Code review UI -bun run dev:review -# Opens http://localhost:5174 - # Portal (share.plannotator.ai) bun run dev:portal @@ -108,17 +97,13 @@ bun run dev:marketing When you're ready to test with actual plugin integration: ```bash -# Build plan review UI +# Build hook plugin (includes frontend HTML) bun run build:hook # Output: apps/hook/dist/index.html -# Build code review UI -bun run build:review -# Output: apps/review/dist/index.html - # Build OpenCode plugin bun run build:opencode -# Copies HTML from hook/review dist folders +# Copies HTML from hook dist folder # Build everything bun run build @@ -127,7 +112,7 @@ bun run build ### Important Build Note -**The OpenCode plugin copies pre-built HTML files from hook and review dist folders.** +**The OpenCode plugin copies pre-built HTML files from the hook dist folder.** When making UI changes: @@ -143,7 +128,7 @@ bun run build:hook && bun run build:opencode bun run build:opencode # Uses stale HTML from previous build! ``` -Always rebuild hook/review apps BEFORE building OpenCode if you changed UI code. +Always rebuild the hook app BEFORE building OpenCode if you changed UI code. --- @@ -182,7 +167,7 @@ UI test scripts simulate plugin behavior locally: **`test-opencode-review.sh`** -1. Builds review app (`bun run build:review`) +1. Builds hook app (`bun run build:hook`) 2. Starts review server with sample git diff 3. Opens browser with code review UI 4. Verifies "OpenCode" badge + "Send Feedback" button (not "Copy Feedback") @@ -205,12 +190,12 @@ See [tests/README.md](../tests/README.md) for additional integration and utility ### Manual Testing Workflow -1. **Make your changes** in `packages/ui/` or `packages/editor/` +1. **Make your changes** in `packages/ui/` or `apps/frontend/` 2. **Choose testing method:** - **Option A:** Dev server (fast iteration) ```bash - bun run dev:hook + bun run dev:frontend ``` - **Option B:** Build and test with script (integration test) ```bash @@ -303,7 +288,7 @@ bun install **Solutions:** 1. Hard refresh browser: Cmd+Shift+R (Mac) or Ctrl+Shift+R (Windows/Linux) -2. Restart dev server: Ctrl+C then `bun run dev:hook` +2. Restart dev server: Ctrl+C then `bun run dev:frontend` 3. Clear browser cache 4. Check terminal for errors diff --git a/tests/manual/local/sandbox-codex.sh b/tests/manual/local/sandbox-codex.sh index 310c7c4cd..a4b964f97 100755 --- a/tests/manual/local/sandbox-codex.sh +++ b/tests/manual/local/sandbox-codex.sh @@ -49,7 +49,7 @@ echo "" echo "Compiling plannotator binary..." cd "$PROJECT_ROOT" bun run build:hook > /dev/null 2>&1 -bun run build:review > /dev/null 2>&1 +bun run build:hook > /dev/null 2>&1 bun build apps/hook/server/index.ts --compile --outfile ~/.local/bin/plannotator 2>&1 echo "Binary compiled to ~/.local/bin/plannotator" echo "" diff --git a/tests/manual/local/sandbox-gemini.sh b/tests/manual/local/sandbox-gemini.sh index 4da6266f4..b2016569c 100755 --- a/tests/manual/local/sandbox-gemini.sh +++ b/tests/manual/local/sandbox-gemini.sh @@ -25,7 +25,7 @@ echo "" # --- Build --- echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review 2>&1 | tail -1 +bun run build:hook 2>&1 | tail -1 bun run build:hook 2>&1 | tail -1 echo "" diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index cf759ea7e..2f7e7376c 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -120,8 +120,7 @@ echo "" # Build the plugin (includes building dependencies) echo "Building plugin..." cd "$PROJECT_ROOT" -bun run build:hook > /dev/null 2>&1 # Required: opencode copies HTML from hook dist -bun run build:review > /dev/null 2>&1 # Required: opencode copies HTML from review dist +bun run build:hook > /dev/null 2>&1 bun run build:opencode echo "" diff --git a/tests/manual/local/sandbox-pi.sh b/tests/manual/local/sandbox-pi.sh index acc2cb50e..041117b3a 100755 --- a/tests/manual/local/sandbox-pi.sh +++ b/tests/manual/local/sandbox-pi.sh @@ -9,7 +9,7 @@ # --no-git Don't initialize git repo # # What it does: -# 1. Builds the Pi extension (copies HTML from hook/review) +# 1. Builds the frontend and Pi extension # 2. Creates a temp directory with sample files # 3. Installs the local extension via `pi install` # 4. Launches Pi in the sandbox @@ -49,7 +49,7 @@ echo "" echo "Building Pi extension..." cd "$PROJECT_ROOT" bun run build:hook > /dev/null 2>&1 -bun run build:review > /dev/null 2>&1 +bun run build:hook > /dev/null 2>&1 cd "$PI_EXT_DIR" bun run build diff --git a/tests/manual/local/test-bulk-plans.sh b/tests/manual/local/test-bulk-plans.sh index b51d13209..345b1a349 100755 --- a/tests/manual/local/test-bulk-plans.sh +++ b/tests/manual/local/test-bulk-plans.sh @@ -9,7 +9,7 @@ # 2. Opens browser for you to review # 3. After approve/deny, moves to next plan # -# Great for bulk-testing Obsidian integration. +# Great for bulk-testing plan review with real plans. set -e diff --git a/tests/manual/local/test-codex-plan-review-e2e.sh b/tests/manual/local/test-codex-plan-review-e2e.sh index 0f3a19fa0..36878fac8 100755 --- a/tests/manual/local/test-codex-plan-review-e2e.sh +++ b/tests/manual/local/test-codex-plan-review-e2e.sh @@ -288,7 +288,7 @@ if [[ "$SKIP_BUILD" != "true" ]]; then echo "Building hook + review apps..." ( cd "$PROJECT_ROOT" - "$BUN_BIN" run build:review >/dev/null + "$BUN_BIN" run build:hook >/dev/null "$BUN_BIN" run build:hook >/dev/null ) fi diff --git a/tests/manual/local/test-external-annotations.sh b/tests/manual/local/test-external-annotations.sh index dc3758ba2..fd412e8de 100755 --- a/tests/manual/local/test-external-annotations.sh +++ b/tests/manual/local/test-external-annotations.sh @@ -28,7 +28,7 @@ echo "" # Build first to ensure latest code echo "Building review app..." cd "$PROJECT_ROOT" -bun run --cwd apps/review build 2>&1 | tail -3 +bun run build:hook 2>&1 | tail -3 echo "" echo "Building hook (copies review HTML)..." diff --git a/tests/manual/local/test-hook.sh b/tests/manual/local/test-hook.sh index 6a247f183..f7290780c 100755 --- a/tests/manual/local/test-hook.sh +++ b/tests/manual/local/test-hook.sh @@ -21,7 +21,7 @@ echo "" # Build first to ensure latest code echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review +bun run build:hook bun run build:hook echo "" diff --git a/tests/manual/local/test-jj-review.sh b/tests/manual/local/test-jj-review.sh index 06f6d6b80..a160c270e 100755 --- a/tests/manual/local/test-jj-review.sh +++ b/tests/manual/local/test-jj-review.sh @@ -31,7 +31,7 @@ echo "" echo "Building review app..." cd "$PROJECT_ROOT" -bun run build:review +bun run build:hook echo "" echo "Setting up JJ sandbox..." diff --git a/tests/manual/local/test-loose-list.sh b/tests/manual/local/test-loose-list.sh index 68bc322d5..cd453b3e4 100755 --- a/tests/manual/local/test-loose-list.sh +++ b/tests/manual/local/test-loose-list.sh @@ -17,7 +17,7 @@ echo "" # Build echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review +bun run build:hook bun run build:hook echo "" diff --git a/tests/manual/local/test-opencode-review.sh b/tests/manual/local/test-opencode-review.sh index 47bd8ac50..5ddab06c1 100755 --- a/tests/manual/local/test-opencode-review.sh +++ b/tests/manual/local/test-opencode-review.sh @@ -21,7 +21,7 @@ echo "" # Build first to ensure latest code echo "Building review app..." cd "$PROJECT_ROOT" -bun run build:review +bun run build:hook echo "" echo "Starting review server with OpenCode origin..." diff --git a/tests/manual/local/test-sessions.sh b/tests/manual/local/test-sessions.sh index c05d74413..8cb660950 100755 --- a/tests/manual/local/test-sessions.sh +++ b/tests/manual/local/test-sessions.sh @@ -104,7 +104,7 @@ echo "" if [[ "${1:-}" != "--skip-build" ]]; then echo "--- Building ---" cd "$PROJECT_ROOT" - bun run build:review 2>&1 | tail -1 + bun run build:hook 2>&1 | tail -1 bun run build:hook 2>&1 | tail -1 echo "" else diff --git a/tests/manual/local/test-worktree-review.sh b/tests/manual/local/test-worktree-review.sh index ea4f2b475..391364e99 100755 --- a/tests/manual/local/test-worktree-review.sh +++ b/tests/manual/local/test-worktree-review.sh @@ -28,7 +28,7 @@ echo "" # Build first to ensure latest code echo "Building review app..." cd "$PROJECT_ROOT" -bun run build:review +bun run build:hook echo "" echo "Setting up sandbox with worktrees..." diff --git a/tests/manual/path-detection/run-annotate-in-tree.sh b/tests/manual/path-detection/run-annotate-in-tree.sh index 0e3f34230..eda3c9713 100755 --- a/tests/manual/path-detection/run-annotate-in-tree.sh +++ b/tests/manual/path-detection/run-annotate-in-tree.sh @@ -27,7 +27,7 @@ echo "" # Build echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review > /dev/null 2>&1 +bun run build:hook > /dev/null 2>&1 bun run build:hook > /dev/null 2>&1 echo "Build complete." echo "" diff --git a/tests/manual/path-detection/run-annotate-out-of-tree.sh b/tests/manual/path-detection/run-annotate-out-of-tree.sh index ab5720b03..0c98d8a02 100755 --- a/tests/manual/path-detection/run-annotate-out-of-tree.sh +++ b/tests/manual/path-detection/run-annotate-out-of-tree.sh @@ -30,7 +30,7 @@ echo "" # Build echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review > /dev/null 2>&1 +bun run build:hook > /dev/null 2>&1 bun run build:hook > /dev/null 2>&1 echo "Build complete." echo "" diff --git a/tests/manual/path-detection/run-plan.sh b/tests/manual/path-detection/run-plan.sh index f0c2f6797..41ca1f0e7 100755 --- a/tests/manual/path-detection/run-plan.sh +++ b/tests/manual/path-detection/run-plan.sh @@ -30,7 +30,7 @@ echo "" # Build echo "Building review + hook..." cd "$PROJECT_ROOT" -bun run build:review > /dev/null 2>&1 +bun run build:hook > /dev/null 2>&1 bun run build:hook > /dev/null 2>&1 echo "Build complete." echo "" diff --git a/tests/manual/test-external-annotations.ts b/tests/manual/test-external-annotations.ts deleted file mode 100644 index f63ac88be..000000000 --- a/tests/manual/test-external-annotations.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * Test script for External Annotations API - * - * Usage: - * bun run tests/manual/test-external-annotations.ts - * - * What it does: - * 1. Starts the review server with a sample diff (sandbox mode) - * 2. Opens browser so you can see annotations arrive in real-time - * 3. Sends a batch of CodeAnnotation-shaped annotations over timed intervals - * 4. Demonstrates single add, batch add, delete, and clear operations - * 5. Prints server decision when you submit feedback - */ - -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; - -// @ts-ignore - Bun import attribute for text -import html from "../../apps/review/dist/index.html" with { type: "text" }; - -// --------------------------------------------------------------------------- -// Sample diff (same as test-review-server.ts) -// --------------------------------------------------------------------------- - -const sampleDiff = `diff --git a/src/utils/parser.ts b/src/utils/parser.ts -index 1234567..abcdefg 100644 ---- a/src/utils/parser.ts -+++ b/src/utils/parser.ts -@@ -10,6 +10,8 @@ export function parseMarkdown(input: string): Block[] { - const blocks: Block[] = []; - const lines = input.split('\\n'); - -+ // Handle empty input -+ if (lines.length === 0) return blocks; -+ - for (const line of lines) { - if (line.startsWith('#')) { - blocks.push({ type: 'heading', content: line }); -@@ -25,7 +27,7 @@ export function parseMarkdown(input: string): Block[] { - } - - export function formatBlock(block: Block): string { -- return block.content; -+ return block.content.trim(); - } - - // New helper function -diff --git a/src/components/App.tsx b/src/components/App.tsx -index 7654321..fedcba9 100644 ---- a/src/components/App.tsx -+++ b/src/components/App.tsx -@@ -1,5 +1,6 @@ - import React, { useState, useEffect } from 'react'; - import { parseMarkdown } from '../utils/parser'; -+import { formatBlock } from '../utils/parser'; - - export function App() { - const [blocks, setBlocks] = useState([]); -@@ -15,6 +16,10 @@ export function App() { - fetchData(); - }, []); - -+ const handleFormat = (block: Block) => { -+ return formatBlock(block); -+ }; -+ - return ( -
-

Plannotator

-@@ -22,7 +27,7 @@ export function App() { - {blocks.map((block, i) => ( -
- {block.type} -- {block.content} -+ {handleFormat(block)} -
- ))} -
-diff --git a/package.json b/package.json -index 1111111..2222222 100644 ---- a/package.json -+++ b/package.json -@@ -5,7 +5,8 @@ - "scripts": { - "dev": "vite", - "build": "vite build", -- "test": "vitest" -+ "test": "vitest", -+ "lint": "eslint src/" - }, - "dependencies": { - "react": "^18.2.0" -`; - -// --------------------------------------------------------------------------- -// Annotation sequences — CodeAnnotation shape for review mode -// --------------------------------------------------------------------------- - -const ANNOTATIONS = { - // Wave 1: Single comment annotation - wave1: { - source: "eslint", - type: "concern", - filePath: "src/utils/parser.ts", - lineStart: 12, - lineEnd: 12, - side: "new", - text: "Unexpected empty return. Consider returning an explicit empty array for clarity.", - author: "eslint", - }, - - // Wave 2: Batch of 3 annotations - wave2: [ - { - source: "eslint", - type: "concern", - filePath: "src/components/App.tsx", - lineStart: 3, - lineEnd: 3, - side: "new", - text: "Duplicate import from '../utils/parser'. Merge with line 2.", - author: "eslint", - }, - { - source: "typescript", - type: "concern", - filePath: "src/components/App.tsx", - lineStart: 19, - lineEnd: 21, - side: "new", - text: "Parameter 'block' implicitly has an 'any' type. Add explicit type annotation.", - author: "typescript", - }, - { - source: "eslint", - type: "suggestion", - filePath: "src/utils/parser.ts", - lineStart: 28, - lineEnd: 28, - side: "new", - text: "Consider using optional chaining for safer access.", - suggestedCode: "return block.content?.trim() ?? '';", - originalCode: "return block.content.trim();", - author: "eslint", - }, - ], - - // Wave 3: Coverage comment - wave3: { - source: "coverage", - type: "comment", - filePath: "src/utils/parser.ts", - lineStart: 10, - lineEnd: 15, - side: "new", - text: "Branch coverage: 67% (2/3 branches). Missing: empty input path.", - author: "coverage", - }, - - // Wave 4: Package.json comment - wave4: { - source: "depcheck", - type: "concern", - filePath: "package.json", - lineStart: 9, - lineEnd: 9, - side: "new", - text: "eslint is referenced in scripts but not listed in devDependencies.", - author: "depcheck", - }, -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const BASE = "/api/external-annotations"; - -async function post(port: number, body: object) { - const res = await fetch(`http://localhost:${port}${BASE}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return res.json(); -} - -async function del(port: number, params: string) { - const res = await fetch(`http://localhost:${port}${BASE}?${params}`, { - method: "DELETE", - }); - return res.json(); -} - -function log(msg: string) { - const ts = new Date().toLocaleTimeString("en-US", { hour12: false }); - console.error(`[${ts}] ${msg}`); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -log("Starting Code Review server with external annotations demo..."); - -const server = await startReviewServer({ - rawPatch: sampleDiff, - gitRef: "demo (external annotations)", - origin: "claude-code", - htmlContent: html as unknown as string, - onReady: (url, isRemote, port) => { - handleReviewServerReady(url, isRemote, port); - log(`Server running at ${url}`); - log(""); - log("=== External Annotations Demo ==="); - log("Watch the browser — annotations will arrive in real-time."); - log(""); - - // Schedule annotation waves - scheduleWaves(port); - }, -}); - -async function scheduleWaves(port: number) { - // Wave 1: Single annotation after 2s - await Bun.sleep(2000); - log("Wave 1: Sending single eslint concern..."); - const r1 = await post(port, ANNOTATIONS.wave1); - log(` → Created: ${JSON.stringify(r1.ids)}`); - - // Wave 2: Batch of 3 after 3s - await Bun.sleep(3000); - log("Wave 2: Sending batch of 3 annotations (eslint + typescript)..."); - const r2 = await post(port, { annotations: ANNOTATIONS.wave2 }); - log(` → Created: ${JSON.stringify(r2.ids)}`); - - // Wave 3: Coverage comment after 3s - await Bun.sleep(3000); - log("Wave 3: Sending coverage comment..."); - const r3 = await post(port, ANNOTATIONS.wave3); - log(` → Created: ${JSON.stringify(r3.ids)}`); - - // Wave 4: One more after 2s - await Bun.sleep(2000); - log("Wave 4: Sending depcheck concern..."); - const r4 = await post(port, ANNOTATIONS.wave4); - log(` → Created: ${JSON.stringify(r4.ids)}`); - - // Wave 5: Delete the first annotation after 3s - await Bun.sleep(3000); - const firstId = r1.ids[0]; - log(`Wave 5: Deleting first annotation (${firstId})...`); - await del(port, `id=${firstId}`); - log(` → Deleted`); - - // Wave 6: Clear all eslint annotations after 4s - await Bun.sleep(4000); - log("Wave 6: Clearing all eslint annotations..."); - const r6 = await del(port, "source=eslint"); - log(` → Cleared ${r6.removed} eslint annotations`); - - log(""); - log("=== Demo complete ==="); - log("Remaining annotations should be: coverage + depcheck + typescript"); - log("Submit feedback or close the browser when done."); -} - -// Wait for user to submit -const result = await server.waitForDecision(); -await Bun.sleep(1500); -server.stop(); - -log(""); -log("Result:"); -console.log(JSON.stringify(result, null, 2)); -process.exit(0); diff --git a/tests/manual/test-jj-review.ts b/tests/manual/test-jj-review.ts deleted file mode 100644 index e35330102..000000000 --- a/tests/manual/test-jj-review.ts +++ /dev/null @@ -1,1216 +0,0 @@ -/** - * Manual sandbox for JJ-backed code review. - * - * This creates a realistic local setup: - * - a bare Git repository that behaves like a tiny GitHub remote - * - a colocated JJ/Git working repo cloned from that remote - * - one committed JJ change after trunk - * - one current working-copy change on top - * - * Usage: - * bun run tests/manual/test-jj-review.ts [--keep] [--setup-only] [--with-evolog] - * - * Flags: - * --keep Don't delete the sandbox after the server exits. - * --setup-only Create the sandbox and print the path, but don't start the server. - * --with-evolog Amend the current change a few times before launching, so the - * "Evolution diff" mode appears in the UI with entries to pick from. - * Without this flag a helper script is written to the sandbox that - * you can run yourself at any time: `./create-evolog.sh` - * - * What to test in the review UI: - * 1. The View dropdown lists JJ modes only: - * Current change, Last change, Line of work, All files. - * (With --with-evolog or after running create-evolog.sh: also Evolution diff.) - * 2. Initial view is Current change, even if a saved Git default exists. - * 3. Current change shows only the working-copy commit (@). - * 4. Last change shows only the previous committed JJ change (@-). - * 5. Line of work shows the stack from trunk() to @. - * 6. All files shows the whole repository from root() to @. - * 7. Hide whitespace re-runs JJ diff with -w. - * 8. Staging controls are unavailable because JJ has no Git-style staging. - * 9. (Evolog) Evolution diff shows what changed between amendments of @. - * 10. (Evolog) The EvoLogPicker lists 3+ entries with commit IDs and ages. - * 11. (Evolog) Selecting an older entry re-diffs against that snapshot. - * 12. (Evolog) The "current" entry (index 0) is disabled in the picker. - */ - -import { $ } from "bun"; -import { mkdir, rename, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { - startReviewServer, - handleReviewServerReady, -} from "../../packages/server/review"; -import { - getVcsContext, - resolveInitialDiffType, - runVcsDiff, -} from "../../packages/server/vcs"; - -// @ts-ignore - Bun import attribute for text -import html from "../../apps/review/dist/index.html" with { type: "text" }; - -const SETUP_ONLY = process.argv.includes("--setup-only"); -const WITH_EVOLOG = process.argv.includes("--with-evolog"); -const KEEP = process.argv.includes("--keep") || SETUP_ONLY; - -const sandbox = path.join(tmpdir(), `plannotator-jj-test-${Date.now()}`); -const seedRepo = path.join(sandbox, "seed-git-repo"); -const originRepo = path.join(sandbox, "origin.git"); -const jjRepo = path.join(sandbox, "jj-workspace"); - -function lines(values: string[]): string { - return `${values.join("\n")}\n`; -} - -async function write(relativePath: string, content: string): Promise { - const fullPath = path.join(jjRepo, relativePath); - await mkdir(path.dirname(fullPath), { recursive: true }); - await Bun.write(fullPath, content); -} - -async function writeSeed(relativePath: string, content: string): Promise { - const fullPath = path.join(seedRepo, relativePath); - await mkdir(path.dirname(fullPath), { recursive: true }); - await Bun.write(fullPath, content); -} - -async function createSeedGitRemote(): Promise { - await mkdir(seedRepo, { recursive: true }); - await $`git init -q -b main`.cwd(seedRepo); - await $`git config user.email "plannotator@example.com"`.cwd(seedRepo); - await $`git config user.name "Plannotator Test User"`.cwd(seedRepo); - - await writeSeed(".gitignore", lines([ - "node_modules/", - "dist/", - ".env", - ])); - await writeSeed("package.json", lines([ - "{", - ' "name": "jj-demo-service",', - ' "version": "1.0.0",', - ' "type": "module",', - ' "scripts": {', - ' "dev": "bun run src/index.ts",', - ' "test": "bun test"', - " }", - "}", - ])); - await writeSeed("README.md", lines([ - "# JJ Demo Service", - "", - "A small service used to exercise Plannotator's JJ review modes.", - ])); - await writeSeed("src/index.ts", lines([ - "import { createApp } from './app';", - "import { loadConfig } from './config';", - "", - "const app = createApp(loadConfig());", - "app.start();", - ])); - await writeSeed("src/app.ts", lines([ - "import type { Config } from './config';", - "import { Router } from './router';", - "import { createLogger } from './utils/logger';", - "", - "export function createApp(config: Config) {", - " const router = new Router();", - " const logger = createLogger(config.logLevel);", - "", - " router.get('/health', () => ({ status: 200, body: 'ok' }));", - "", - " return {", - " start() {", - " logger.info(`listening on ${config.port}`);", - " },", - " };", - "}", - ])); - await writeSeed("src/config.ts", lines([ - "export interface Config {", - " port: number;", - " logLevel: 'debug' | 'info' | 'warn' | 'error';", - "}", - "", - "export function loadConfig(): Config {", - " return {", - " port: Number(process.env.PORT ?? 3000),", - " logLevel: 'info',", - " };", - "}", - ])); - await writeSeed("src/router.ts", lines([ - "type Handler = () => { status: number; body: string };", - "", - "export class Router {", - " private routes = new Map();", - "", - " get(path: string, handler: Handler) {", - " this.routes.set(`GET ${path}`, handler);", - " }", - "", - " list() {", - " return [...this.routes.keys()];", - " }", - "}", - ])); - await writeSeed("src/utils/format.ts", lines([ - "export function titleCase(value: string): string {", - " return value", - " .split(' ')", - " .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1))", - " .join(' ');", - "}", - "", - "export function truncate(value: string, maxLength: number): string {", - " return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;", - "}", - ])); - await writeSeed("src/utils/logger.ts", lines([ - "export function createLogger(level: string) {", - " return {", - " info(message: string) {", - " if (level !== 'error') console.log(message);", - " },", - " error(message: string) {", - " console.error(message);", - " },", - " };", - "}", - ])); - await writeSeed("src/utils/math.ts", lines([ - "export function clamp(value: number, min: number, max: number): number {", - " return Math.min(max, Math.max(min, value));", - "}", - ])); - await writeSeed("src/shared/http/client.ts", lines([ - "export interface HttpRequestOptions {", - " method?: 'GET' | 'POST' | 'PUT' | 'DELETE';", - " headers?: Record;", - " body?: unknown;", - "}", - "", - "export async function requestJson(url: string, options: HttpRequestOptions = {}): Promise {", - " const response = await fetch(url, {", - " method: options.method ?? 'GET',", - " headers: {", - " 'content-type': 'application/json',", - " ...options.headers,", - " },", - " body: options.body === undefined ? undefined : JSON.stringify(options.body),", - " });", - "", - " if (!response.ok) {", - " throw new Error(`Request failed: ${response.status}`);", - " }", - "", - " return response.json() as Promise;", - "}", - ])); - await writeSeed("src/features/projects/domain/project.ts", lines([ - "export type ProjectStatus = 'active' | 'paused' | 'archived';", - "", - "export interface Project {", - " id: string;", - " slug: string;", - " name: string;", - " status: ProjectStatus;", - " ownerId: string;", - " updatedAt: string;", - "}", - "", - "export function isArchived(project: Project): boolean {", - " return project.status === 'archived';", - "}", - ])); - await writeSeed("src/features/projects/repositories/project-repository.ts", lines([ - "import type { Project } from '../domain/project';", - "", - "const projects: Project[] = [", - " {", - " id: 'proj_001',", - " slug: 'website-refresh',", - " name: 'Website Refresh',", - " status: 'active',", - " ownerId: 'user_001',", - " updatedAt: '2026-05-01T10:00:00.000Z',", - " },", - " {", - " id: 'proj_002',", - " slug: 'billing-cleanup',", - " name: 'Billing Cleanup',", - " status: 'paused',", - " ownerId: 'user_002',", - " updatedAt: '2026-04-28T16:30:00.000Z',", - " },", - "];", - "", - "export async function listProjects(): Promise {", - " return [...projects];", - "}", - ])); - await writeSeed("src/features/projects/services/project-service.ts", lines([ - "import { isArchived } from '../domain/project';", - "import { listProjects } from '../repositories/project-repository';", - "", - "export async function listVisibleProjects() {", - " const projects = await listProjects();", - " return projects.filter((project) => !isArchived(project));", - "}", - ])); - await writeSeed("src/features/projects/routes.ts", lines([ - "import type { Router } from '../../router';", - "import { listVisibleProjects } from './services/project-service';", - "", - "export function registerProjectRoutes(router: Router) {", - " router.get('/projects', async () => {", - " const projects = await listVisibleProjects();", - " return { status: 200, body: JSON.stringify({ projects }) };", - " });", - "}", - ])); - await writeSeed("src/features/projects/components/project-summary.ts", lines([ - "import type { Project } from '../domain/project';", - "", - "export function renderProjectSummary(project: Project): string {", - " return `${project.name} (${project.status})`;", - "}", - ])); - await writeSeed("src/features/projects/legacy/project-exporter.ts", lines([ - "import type { Project } from '../domain/project';", - "", - "export function exportProjectCsv(projects: Project[]): string {", - " return projects.map((project) => `${project.id},${project.name},${project.status}`).join('\\n');", - "}", - ])); - await writeSeed("src/features/billing/invoices/domain/invoice.ts", lines([ - "export interface Invoice {", - " id: string;", - " accountId: string;", - " totalCents: number;", - " dueAt: string;", - "}", - "", - "export function isOverdue(invoice: Invoice, now = new Date()): boolean {", - " return new Date(invoice.dueAt).getTime() < now.getTime();", - "}", - ])); - await writeSeed("docs/runbooks/deployments/rollback.md", lines([ - "# Rollback Runbook", - "", - "1. Confirm the failing release.", - "2. Roll back the deployment.", - "3. Notify the owning team.", - ])); - - await $`git add -A`.cwd(seedRepo).quiet(); - await $`git commit -q -m "initial service"`.cwd(seedRepo); - await $`git init -q --bare ${originRepo}`; - await $`git remote add origin ${originRepo}`.cwd(seedRepo); - await $`git push -q -u origin main`.cwd(seedRepo); -} - -async function createJjWorkspace(): Promise { - await $`jj git clone --colocate ${originRepo} ${jjRepo}`.quiet(); - await $`jj config set --repo user.name ${JSON.stringify("Plannotator Test User")}`.cwd(jjRepo).quiet(); - await $`jj config set --repo user.email ${JSON.stringify("plannotator@example.com")}`.cwd(jjRepo).quiet(); - - // Change 1: committed JJ change after trunk(). This is what jj-last should show. - await write("src/app.ts", lines([ - "import type { Config } from './config';", - "import { Router } from './router';", - "import { registerProjectRoutes } from './features/projects/routes';", - "import { createLogger } from './utils/logger';", - "import { createRequestContext } from './middleware/request-context';", - "", - "export function createApp(config: Config) {", - " const router = new Router();", - " const logger = createLogger(config.logLevel);", - "", - " router.get('/health', () => ({ status: 200, body: 'ok' }));", - " router.get('/ready', () => ({ status: 200, body: 'ready' }));", - " registerProjectRoutes(router);", - "", - " return {", - " start() {", - " const context = createRequestContext('startup');", - " logger.info(`listening on ${config.port}`, context);", - " },", - " };", - "}", - ])); - await write("src/middleware/request-context.ts", lines([ - "export interface RequestContext {", - " requestId: string;", - " startedAt: number;", - "}", - "", - "export function createRequestContext(prefix = 'req'): RequestContext {", - " return {", - " requestId: `${prefix}-${crypto.randomUUID()}`,", - " startedAt: Date.now(),", - " };", - "}", - ])); - await write("src/features/projects/domain/project-permissions.ts", lines([ - "import type { Project } from './project';", - "", - "export interface ProjectPermissions {", - " canRead: boolean;", - " canWrite: boolean;", - " canArchive: boolean;", - "}", - "", - "export function permissionsFor(project: Project, actorId: string): ProjectPermissions {", - " const isOwner = project.ownerId === actorId;", - " const isArchived = project.status === 'archived';", - " return {", - " canRead: isOwner || !isArchived,", - " canWrite: isOwner && !isArchived,", - " canArchive: isOwner && project.status !== 'archived',", - " };", - "}", - ])); - await write("src/features/projects/services/project-service.ts", lines([ - "import { isArchived } from '../domain/project';", - "import { permissionsFor } from '../domain/project-permissions';", - "import { listProjects } from '../repositories/project-repository';", - "", - "export async function listVisibleProjects(actorId = 'anonymous') {", - " const projects = await listProjects();", - " return projects", - " .filter((project) => !isArchived(project))", - " .map((project) => ({", - " ...project,", - " permissions: permissionsFor(project, actorId),", - " }));", - "}", - "", - "export async function listRecentProjectSlugs(limit = 10): Promise {", - " const projects = await listProjects();", - " return projects", - " .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))", - " .slice(0, limit)", - " .map((project) => project.slug);", - "}", - ])); - await write("src/features/projects/routes.ts", lines([ - "import type { Router } from '../../router';", - "import { listRecentProjectSlugs, listVisibleProjects } from './services/project-service';", - "", - "export function registerProjectRoutes(router: Router) {", - " router.get('/projects', async () => {", - " const projects = await listVisibleProjects('user_001');", - " return { status: 200, body: JSON.stringify({ projects }) };", - " });", - "", - " router.get('/projects/recent', async () => {", - " const slugs = await listRecentProjectSlugs(5);", - " return { status: 200, body: JSON.stringify({ slugs }) };", - " });", - "}", - ])); - await write("src/features/billing/invoices/services/invoice-aging-service.ts", lines([ - "import { isOverdue, type Invoice } from '../domain/invoice';", - "", - "export interface InvoiceAgingBucket {", - " label: string;", - " invoices: Invoice[];", - "}", - "", - "export function bucketInvoicesByAge(invoices: Invoice[], now = new Date()): InvoiceAgingBucket[] {", - " const overdue = invoices.filter((invoice) => isOverdue(invoice, now));", - " const current = invoices.filter((invoice) => !isOverdue(invoice, now));", - " return [", - " { label: 'overdue', invoices: overdue },", - " { label: 'current', invoices: current },", - " ];", - "}", - ])); - await $`jj commit -m "feat: add request context middleware"`.cwd(jjRepo).quiet(); - await $`jj bookmark create review/jj-demo -r @-`.cwd(jjRepo).quiet(); - - // Change 2: current working-copy change (@). This is what jj-current should show. - await write("src/config.ts", lines([ - "export interface Config {", - " port: number;", - " logLevel: 'debug' | 'info' | 'warn' | 'error';", - " enableRequestTracing: boolean;", - "}", - "", - "export function loadConfig(): Config {", - " return {", - " port: Number(process.env.PORT ?? 3000),", - " logLevel: (process.env.LOG_LEVEL as Config['logLevel']) ?? 'info',", - " enableRequestTracing: process.env.REQUEST_TRACING === '1',", - " };", - "}", - ])); - await write("src/utils/logger.ts", lines([ - "export function createLogger(level: string) {", - " return {", - " info(message: string, data?: unknown) {", - " if (level !== 'error') console.log(message, data ?? '');", - " },", - " warn(message: string, data?: unknown) {", - " if (level !== 'error') console.warn(message, data ?? '');", - " },", - " error(message: string, data?: unknown) {", - " console.error(message, data ?? '');", - " },", - " };", - "}", - ])); - await rename( - path.join(jjRepo, "src/utils/format.ts"), - path.join(jjRepo, "src/utils/text.ts"), - ); - await write("src/utils/text.ts", lines([ - "export function titleCase(value: string): string {", - " return value", - " .trim()", - " .split(/\\s+/)", - " .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1))", - " .join(' ');", - "}", - "", - "export function truncate(value: string, maxLength: number): string {", - " if (maxLength < 4) return value.slice(0, maxLength);", - " return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;", - "}", - "", - "export function slugify(value: string): string {", - " return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');", - "}", - ])); - await rm(path.join(jjRepo, "src/utils/math.ts")); - await write("src/services/audit-log.ts", lines([ - "export interface AuditEvent {", - " type: string;", - " actor: string;", - " createdAt: string;", - "}", - "", - "export function recordAuditEvent(type: string, actor = 'system'): AuditEvent {", - " return {", - " type,", - " actor,", - " createdAt: new Date().toISOString(),", - " };", - "}", - ])); - await write("src/features/projects/repositories/project-repository.ts", lines([ - "import type { Project } from '../domain/project';", - "", - "const projectsBySlug = new Map([", - " ['website-refresh', {", - " id: 'proj_001',", - " slug: 'website-refresh',", - " name: 'Website Refresh',", - " status: 'active',", - " ownerId: 'user_001',", - " updatedAt: '2026-05-01T10:00:00.000Z',", - " }],", - " ['billing-cleanup', {", - " id: 'proj_002',", - " slug: 'billing-cleanup',", - " name: 'Billing Cleanup',", - " status: 'paused',", - " ownerId: 'user_002',", - " updatedAt: '2026-04-28T16:30:00.000Z',", - " }],", - "]);", - "", - "export async function listProjects(): Promise {", - " return [...projectsBySlug.values()].map((project) => ({ ...project }));", - "}", - "", - "export async function findProjectBySlug(slug: string): Promise {", - " const project = projectsBySlug.get(slug);", - " return project ? { ...project } : null;", - "}", - "", - "export async function saveProject(project: Project): Promise {", - " projectsBySlug.set(project.slug, { ...project });", - "}", - ])); - await write("src/features/projects/api/v1/types.ts", lines([ - "export interface ProjectReportRequest {", - " slug: string;", - " includeArchived?: boolean;", - " format?: 'json' | 'csv';", - "}", - "", - "export interface ProjectReportResponse {", - " slug: string;", - " generatedAt: string;", - " rows: Array>;", - "}", - ])); - await write("src/features/projects/api/v1/serializers/project-serializer.ts", lines([ - "import type { Project } from '../../../domain/project';", - "", - "export function serializeProject(project: Project) {", - " return {", - " id: project.id,", - " slug: project.slug,", - " displayName: project.name,", - " status: project.status,", - " links: {", - " self: `/api/v1/projects/${project.slug}`,", - " report: `/api/v1/projects/${project.slug}/report`,", - " },", - " };", - "}", - ])); - await write("src/features/projects/api/v1/handlers/export-project-report.ts", lines([ - "import { findProjectBySlug } from '../../../repositories/project-repository';", - "import { serializeProject } from '../serializers/project-serializer';", - "import type { ProjectReportRequest, ProjectReportResponse } from '../types';", - "", - "export async function exportProjectReport(request: ProjectReportRequest): Promise {", - " const project = await findProjectBySlug(request.slug);", - " if (!project) {", - " throw new Error(`Project not found: ${request.slug}`);", - " }", - "", - " const serialized = serializeProject(project);", - " return {", - " slug: serialized.slug,", - " generatedAt: new Date().toISOString(),", - " rows: [", - " { field: 'name', value: serialized.displayName },", - " { field: 'status', value: serialized.status },", - " { field: 'self', value: serialized.links.self },", - " ],", - " };", - "}", - ])); - await write("src/features/projects/jobs/nightly/archive-stale-projects.ts", lines([ - "import { listProjects, saveProject } from '../../repositories/project-repository';", - "", - "const STALE_AFTER_DAYS = 120;", - "", - "export async function archiveStaleProjects(now = new Date()): Promise {", - " const projects = await listProjects();", - " let archived = 0;", - "", - " for (const project of projects) {", - " const ageMs = now.getTime() - new Date(project.updatedAt).getTime();", - " const ageDays = ageMs / (1000 * 60 * 60 * 24);", - " if (project.status === 'paused' && ageDays > STALE_AFTER_DAYS) {", - " await saveProject({ ...project, status: 'archived' });", - " archived += 1;", - " }", - " }", - "", - " return archived;", - "}", - ])); - await write("src/features/billing/invoices/domain/invoice.ts", lines([ - "export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void';", - "", - "export interface Invoice {", - " id: string;", - " accountId: string;", - " totalCents: number;", - " status: InvoiceStatus;", - " dueAt: string;", - "}", - "", - "export function isOverdue(invoice: Invoice, now = new Date()): boolean {", - " return invoice.status === 'open' && new Date(invoice.dueAt).getTime() < now.getTime();", - "}", - ])); - await write("src/features/billing/invoices/repositories/invoice-repository.ts", lines([ - "import type { Invoice } from '../domain/invoice';", - "", - "export async function listOpenInvoices(accountId: string): Promise {", - " return [", - " {", - " id: 'inv_001',", - " accountId,", - " totalCents: 24000,", - " status: 'open',", - " dueAt: '2026-05-15T00:00:00.000Z',", - " },", - " ];", - "}", - ])); - await write("src/shared/http/client.ts", lines([ - "export interface HttpRequestOptions {", - " method?: 'GET' | 'POST' | 'PUT' | 'DELETE';", - " headers?: Record;", - " body?: unknown;", - "}", - "", - "export async function requestJson(url: string, options: HttpRequestOptions = {}): Promise {", - " const response = await fetch(url, {", - " method: options.method ?? 'GET',", - " headers: {", - " 'content-type': 'application/json',", - " ...options.headers,", - " },", - " body: options.body === undefined ? undefined : JSON.stringify(options.body),", - " });", - "", - " if (!response.ok) {", - " throw new Error(`Request failed: ${response.status}`);", - " }", - "", - " return response.json() as Promise;", - "}", - ])); - await mkdir(path.join(jjRepo, "src/features/projects/ui"), { recursive: true }); - await rename( - path.join(jjRepo, "src/features/projects/components/project-summary.ts"), - path.join(jjRepo, "src/features/projects/ui/project-summary-card.ts"), - ); - await write("src/features/projects/ui/project-summary-card.ts", lines([ - "import type { Project } from '../domain/project';", - "", - "export function renderProjectSummaryCard(project: Project): string {", - " const badge = project.status === 'active' ? 'green' : 'gray';", - " return `
${project.name}
`;", - "}", - ])); - await rm(path.join(jjRepo, "src/features/projects/legacy/project-exporter.ts")); - await write("docs/runbooks/projects/export-reports.md", lines([ - "# Project Report Exports", - "", - "Project reports are generated from `/api/v1/projects/:slug/report`.", - "", - "## Manual Checks", - "", - "1. Confirm the project slug exists.", - "2. Generate a JSON report.", - "3. Validate links in the serialized payload.", - ])); - - // Force JJ to snapshot the current working-copy change before Plannotator reads it. - await $`jj status`.cwd(jjRepo).quiet(); -} - -/** - * Amend the current working-copy change several times to create a realistic - * evolution log. Simulates a typical iteration cycle: first pass, then - * fixing issues you spotted, then responding to review feedback, then a - * final polish. After this, `jj evolog -r @` will show 5 entries. - */ -async function createEvologHistory(): Promise { - // Amendment 1: noticed the audit log service is missing a metadata field - // that other services expect. Quick fix while the change is still fresh. - await write("src/services/audit-log.ts", lines([ - "export interface AuditEvent {", - " type: string;", - " actor: string;", - " resource?: string;", - " createdAt: string;", - "}", - "", - "export function recordAuditEvent(type: string, actor = 'system', resource?: string): AuditEvent {", - " return {", - " type,", - " actor,", - " resource,", - " createdAt: new Date().toISOString(),", - " };", - "}", - ])); - await $`jj describe -m "feat: add config, audit log, and project API improvements"`.cwd(jjRepo).quiet(); - await $`jj status`.cwd(jjRepo).quiet(); - - // Amendment 2: reviewer pointed out the config should validate env vars - // instead of silently accepting garbage values. Also add a debug level - // to the logger since we're adding enableRequestTracing. - await write("src/config.ts", lines([ - "export interface Config {", - " port: number;", - " logLevel: 'debug' | 'info' | 'warn' | 'error';", - " enableRequestTracing: boolean;", - " maxRetries: number;", - "}", - "", - "const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);", - "", - "export function loadConfig(): Config {", - " const rawLogLevel = process.env.LOG_LEVEL ?? 'info';", - " if (!VALID_LOG_LEVELS.has(rawLogLevel)) {", - " throw new Error(`Invalid LOG_LEVEL: ${rawLogLevel}`);", - " }", - "", - " return {", - " port: Number(process.env.PORT ?? 3000),", - " logLevel: rawLogLevel as Config['logLevel'],", - " enableRequestTracing: process.env.REQUEST_TRACING === '1',", - " maxRetries: Number(process.env.MAX_RETRIES ?? 3),", - " };", - "}", - ])); - await write("src/utils/logger.ts", lines([ - "export type LogLevel = 'debug' | 'info' | 'warn' | 'error';", - "", - "const LEVEL_PRIORITY: Record = {", - " debug: 0,", - " info: 1,", - " warn: 2,", - " error: 3,", - "};", - "", - "export function createLogger(level: string) {", - " const threshold = LEVEL_PRIORITY[level as LogLevel] ?? 1;", - "", - " return {", - " debug(message: string, data?: unknown) {", - " if (threshold <= 0) console.debug(message, data ?? '');", - " },", - " info(message: string, data?: unknown) {", - " if (threshold <= 1) console.log(message, data ?? '');", - " },", - " warn(message: string, data?: unknown) {", - " if (threshold <= 2) console.warn(message, data ?? '');", - " },", - " error(message: string, data?: unknown) {", - " console.error(message, data ?? '');", - " },", - " };", - "}", - ])); - await $`jj describe -m "feat: add config validation, structured logger, audit log, and project API"`.cwd(jjRepo).quiet(); - await $`jj status`.cwd(jjRepo).quiet(); - - // Amendment 3: realized the archive job threshold should be configurable - // via config rather than hardcoded. Wire it through. - await write("src/config.ts", lines([ - "export interface Config {", - " port: number;", - " logLevel: 'debug' | 'info' | 'warn' | 'error';", - " enableRequestTracing: boolean;", - " maxRetries: number;", - " staleProjectDays: number;", - "}", - "", - "const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);", - "", - "export function loadConfig(): Config {", - " const rawLogLevel = process.env.LOG_LEVEL ?? 'info';", - " if (!VALID_LOG_LEVELS.has(rawLogLevel)) {", - " throw new Error(`Invalid LOG_LEVEL: ${rawLogLevel}`);", - " }", - "", - " return {", - " port: Number(process.env.PORT ?? 3000),", - " logLevel: rawLogLevel as Config['logLevel'],", - " enableRequestTracing: process.env.REQUEST_TRACING === '1',", - " maxRetries: Number(process.env.MAX_RETRIES ?? 3),", - " staleProjectDays: Number(process.env.STALE_PROJECT_DAYS ?? 120),", - " };", - "}", - ])); - await write("src/features/projects/jobs/nightly/archive-stale-projects.ts", lines([ - "import { listProjects, saveProject } from '../../repositories/project-repository';", - "", - "export async function archiveStaleProjects(", - " staleAfterDays: number,", - " now = new Date(),", - "): Promise {", - " const projects = await listProjects();", - " let archived = 0;", - "", - " for (const project of projects) {", - " const ageMs = now.getTime() - new Date(project.updatedAt).getTime();", - " const ageDays = ageMs / (1000 * 60 * 60 * 24);", - " if (project.status === 'paused' && ageDays > staleAfterDays) {", - " await saveProject({ ...project, status: 'archived' });", - " archived += 1;", - " }", - " }", - "", - " return archived;", - "}", - ])); - await $`jj describe -m "feat: config validation, structured logger, configurable archive threshold"`.cwd(jjRepo).quiet(); - await $`jj status`.cwd(jjRepo).quiet(); - - // Amendment 4: final cleanup — fix the invoice repository to use the - // status field properly and tighten the http client types. The kind of - // last-minute polish before marking a change as ready. - await write("src/features/billing/invoices/repositories/invoice-repository.ts", lines([ - "import type { Invoice } from '../domain/invoice';", - "", - "export async function listOpenInvoices(accountId: string): Promise {", - " return [", - " {", - " id: 'inv_001',", - " accountId,", - " totalCents: 24000,", - " status: 'open',", - " dueAt: '2026-05-15T00:00:00.000Z',", - " },", - " ];", - "}", - "", - "export async function listOverdueInvoices(accountId: string, now = new Date()): Promise {", - " const invoices = await listOpenInvoices(accountId);", - " return invoices.filter((inv) => new Date(inv.dueAt).getTime() < now.getTime());", - "}", - ])); - await write("src/shared/http/client.ts", lines([ - "export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';", - "", - "export interface HttpRequestOptions {", - " method?: HttpMethod;", - " headers?: Record;", - " body?: unknown;", - " signal?: AbortSignal;", - "}", - "", - "export async function requestJson(url: string, options: HttpRequestOptions = {}): Promise {", - " const response = await fetch(url, {", - " method: options.method ?? 'GET',", - " headers: {", - " 'content-type': 'application/json',", - " ...options.headers,", - " },", - " body: options.body === undefined ? undefined : JSON.stringify(options.body),", - " signal: options.signal,", - " });", - "", - " if (!response.ok) {", - " throw new Error(`Request failed: ${response.status} ${response.statusText}`);", - " }", - "", - " return response.json() as Promise;", - "}", - ])); - await $`jj describe -m "feat: config validation, structured logger, project archive, invoice & http cleanup"`.cwd(jjRepo).quiet(); - await $`jj status`.cwd(jjRepo).quiet(); -} - -/** - * Write a standalone shell script into the sandbox that creates evolog - * history when run. This lets the user launch the sandbox without evolog, - * verify the base modes work, then run the script and refresh to see the - * Evolution diff mode appear. - */ -async function writeEvologHelperScript(): Promise { - const script = `#!/usr/bin/env bash -set -euo pipefail - -# Creates evolution history for the current JJ change (@) by amending it -# four times, simulating a realistic iteration cycle. After running this, -# refresh the Plannotator review UI — the "Evolution diff" mode will -# appear in the diff type picker with 5 entries to compare between. - -cd "${jjRepo}" - -echo "Amendment 1/4: adding resource field to audit log..." -cat > src/services/audit-log.ts << 'TSEOF' -export interface AuditEvent { - type: string; - actor: string; - resource?: string; - createdAt: string; -} - -export function recordAuditEvent(type: string, actor = 'system', resource?: string): AuditEvent { - return { - type, - actor, - resource, - createdAt: new Date().toISOString(), - }; -} -TSEOF -jj describe -m "feat: add config, audit log, and project API improvements" -jj status > /dev/null - -echo "Amendment 2/4: adding config validation and structured logger..." -cat > src/config.ts << 'TSEOF' -export interface Config { - port: number; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - enableRequestTracing: boolean; - maxRetries: number; -} - -const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']); - -export function loadConfig(): Config { - const rawLogLevel = process.env.LOG_LEVEL ?? 'info'; - if (!VALID_LOG_LEVELS.has(rawLogLevel)) { - throw new Error(\\\`Invalid LOG_LEVEL: \\\${rawLogLevel}\\\`); - } - - return { - port: Number(process.env.PORT ?? 3000), - logLevel: rawLogLevel as Config['logLevel'], - enableRequestTracing: process.env.REQUEST_TRACING === '1', - maxRetries: Number(process.env.MAX_RETRIES ?? 3), - }; -} -TSEOF -cat > src/utils/logger.ts << 'TSEOF' -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -const LEVEL_PRIORITY: Record = { - debug: 0, - info: 1, - warn: 2, - error: 3, -}; - -export function createLogger(level: string) { - const threshold = LEVEL_PRIORITY[level as LogLevel] ?? 1; - - return { - debug(message: string, data?: unknown) { - if (threshold <= 0) console.debug(message, data ?? ''); - }, - info(message: string, data?: unknown) { - if (threshold <= 1) console.log(message, data ?? ''); - }, - warn(message: string, data?: unknown) { - if (threshold <= 2) console.warn(message, data ?? ''); - }, - error(message: string, data?: unknown) { - console.error(message, data ?? ''); - }, - }; -} -TSEOF -jj describe -m "feat: config validation, structured logger, audit log, and project API" -jj status > /dev/null - -echo "Amendment 3/4: making archive threshold configurable..." -cat > src/config.ts << 'TSEOF' -export interface Config { - port: number; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - enableRequestTracing: boolean; - maxRetries: number; - staleProjectDays: number; -} - -const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']); - -export function loadConfig(): Config { - const rawLogLevel = process.env.LOG_LEVEL ?? 'info'; - if (!VALID_LOG_LEVELS.has(rawLogLevel)) { - throw new Error(\\\`Invalid LOG_LEVEL: \\\${rawLogLevel}\\\`); - } - - return { - port: Number(process.env.PORT ?? 3000), - logLevel: rawLogLevel as Config['logLevel'], - enableRequestTracing: process.env.REQUEST_TRACING === '1', - maxRetries: Number(process.env.MAX_RETRIES ?? 3), - staleProjectDays: Number(process.env.STALE_PROJECT_DAYS ?? 120), - }; -} -TSEOF -cat > src/features/projects/jobs/nightly/archive-stale-projects.ts << 'TSEOF' -import { listProjects, saveProject } from '../../repositories/project-repository'; - -export async function archiveStaleProjects( - staleAfterDays: number, - now = new Date(), -): Promise { - const projects = await listProjects(); - let archived = 0; - - for (const project of projects) { - const ageMs = now.getTime() - new Date(project.updatedAt).getTime(); - const ageDays = ageMs / (1000 * 60 * 60 * 24); - if (project.status === 'paused' && ageDays > staleAfterDays) { - await saveProject({ ...project, status: 'archived' }); - archived += 1; - } - } - - return archived; -} -TSEOF -jj describe -m "feat: config validation, structured logger, configurable archive threshold" -jj status > /dev/null - -echo "Amendment 4/4: polish — overdue invoice query and http client cleanup..." -cat > src/features/billing/invoices/repositories/invoice-repository.ts << 'TSEOF' -import type { Invoice } from '../domain/invoice'; - -export async function listOpenInvoices(accountId: string): Promise { - return [ - { - id: 'inv_001', - accountId, - totalCents: 24000, - status: 'open', - dueAt: '2026-05-15T00:00:00.000Z', - }, - ]; -} - -export async function listOverdueInvoices(accountId: string, now = new Date()): Promise { - const invoices = await listOpenInvoices(accountId); - return invoices.filter((inv) => new Date(inv.dueAt).getTime() < now.getTime()); -} -TSEOF -cat > src/shared/http/client.ts << 'TSEOF' -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - -export interface HttpRequestOptions { - method?: HttpMethod; - headers?: Record; - body?: unknown; - signal?: AbortSignal; -} - -export async function requestJson(url: string, options: HttpRequestOptions = {}): Promise { - const response = await fetch(url, { - method: options.method ?? 'GET', - headers: { - 'content-type': 'application/json', - ...options.headers, - }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - signal: options.signal, - }); - - if (!response.ok) { - throw new Error(\\\`Request failed: \\\${response.status} \\\${response.statusText}\\\`); - } - - return response.json() as Promise; -} -TSEOF -jj describe -m "feat: config validation, structured logger, project archive, invoice & http cleanup" -jj status > /dev/null - -echo "" -echo "Done! Evolution log now has 5 entries:" -jj evolog --no-graph -r @ -T 'commit.commit_id().short(8) ++ " " ++ commit.description().first_line() ++ " (" ++ commit.author().timestamp().ago() ++ ")\\n"' -echo "" -echo "Refresh the Plannotator review UI to see the Evolution diff mode." -`; - const scriptPath = path.join(sandbox, "create-evolog.sh"); - await Bun.write(scriptPath, script); - await $`chmod +x ${scriptPath}`.quiet(); -} - -async function printSandboxSummary(): Promise { - const log = await $`jj log --no-graph -T 'change_id.short() ++ " " ++ commit_id.short() ++ " " ++ bookmarks ++ " " ++ remote_bookmarks ++ " " ++ description.first_line() ++ "\n"'`.cwd(jjRepo).quiet(); - const status = await $`jj status`.cwd(jjRepo).quiet(); - - console.error("Sandbox created:"); - console.error(` root: ${sandbox}`); - console.error(` jj repo: ${jjRepo}`); - console.error(` git remote: ${originRepo}`); - console.error(""); - console.error("JJ graph:"); - console.error(log.text().trimEnd()); - console.error(""); - console.error("JJ status:"); - console.error(status.text().trimEnd()); - console.error(""); - console.error("Useful manual commands:"); - console.error(` cd ${jjRepo}`); - console.error(" jj diff --git -r @"); - console.error(" jj diff --git -r @-"); - console.error(" jj diff --git --from 'heads(::@ & ::(trunk()))' --to @"); - console.error(" jj diff --git --from 'root()' --to @"); - console.error(" jj diff --git -w -r @"); - console.error(" jj evolog -r @"); - console.error(" jj git push --dry-run --bookmark review/jj-demo"); - console.error(""); - console.error("Evolog helper script:"); - console.error(` ${path.join(sandbox, "create-evolog.sh")}`); - console.error(" (Amends @ twice to create evolution history, then refresh the UI)") - console.error(""); -} - -if (!(await $`command -v jj`.quiet().nothrow()).stdout.length) { - console.error("jj is required for this manual sandbox but was not found on PATH."); - process.exit(1); -} - -console.error("=== JJ Review Test ==="); -console.error(""); - -await mkdir(sandbox, { recursive: true }); -await createSeedGitRemote(); -await createJjWorkspace(); -await writeEvologHelperScript(); - -if (WITH_EVOLOG) { - console.error("Creating evolution history (--with-evolog)..."); - await createEvologHistory(); - const evolog = await $`jj evolog --no-graph -r @ -T 'commit.commit_id().short(8) ++ " " ++ commit.description().first_line() ++ "\n"'`.cwd(jjRepo).quiet(); - console.error("Evolog entries:"); - console.error(evolog.text().trimEnd()); - console.error(""); -} - -await printSandboxSummary(); - -if (SETUP_ONLY) { - console.error("--setup-only supplied; not starting the review server."); - process.exit(0); -} - -const gitContext = await getVcsContext(jjRepo); -const initialDiffType = resolveInitialDiffType(gitContext, "merge-base"); -const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch, gitContext.cwd, { - hideWhitespace: false, -}); - -console.error("Starting review server..."); -console.error("Browser should open automatically."); -console.error(""); -console.error("Expected initial state:"); -console.error(" - VCS type: jj"); -console.error(" - Initial View: Current change"); -console.error(" - Base for Line of work: trunk()"); -if (WITH_EVOLOG) { - console.error(" - Evolution diff: available (5 evolog entries)"); - console.error(" - EvoLogPicker: should show entries with commit IDs + ages"); -} else { - console.error(" - Evolution diff: not shown (run create-evolog.sh and refresh)"); -} -console.error(""); - -const server = await startReviewServer({ - rawPatch: diffResult.patch, - gitRef: diffResult.label, - error: diffResult.error, - origin: "claude-code", - diffType: initialDiffType, - gitContext, - sharingEnabled: false, - htmlContent: html as unknown as string, - onReady: (url, isRemote, port) => handleReviewServerReady(url, isRemote, port), -}); - -const result = await server.waitForDecision(); -await Bun.sleep(1500); -server.stop(); - -console.error(""); -console.error("Feedback received:"); -console.log(JSON.stringify(result, null, 2)); - -if (!KEEP) { - console.error(""); - console.error("Cleaning up sandbox..."); - await rm(sandbox, { recursive: true, force: true }); - console.error("Done."); -} else { - console.error(""); - console.error(`Sandbox kept at: ${sandbox}`); - console.error("To clean up manually:"); - console.error(` rm -rf ${sandbox}`); -} - -process.exit(0); diff --git a/tests/manual/test-review-server.ts b/tests/manual/test-review-server.ts deleted file mode 100644 index 77b17d5b3..000000000 --- a/tests/manual/test-review-server.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Test script for Code Review server - * - * Usage: - * bun run tests/manual/test-review-server.ts - * - * What it does: - * 1. Starts the review server with OpenCode origin - * 2. Opens browser for you to test the UI (should show "OpenCode" badge) - * 3. Prints the feedback result when submitted - */ - -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; - -// @ts-ignore - Bun import attribute for text -import html from "../../apps/review/dist/index.html" with { type: "text" }; - -// Sample git diff for testing -const sampleDiff = `diff --git a/src/utils/parser.ts b/src/utils/parser.ts -index 1234567..abcdefg 100644 ---- a/src/utils/parser.ts -+++ b/src/utils/parser.ts -@@ -10,6 +10,8 @@ export function parseMarkdown(input: string): Block[] { - const blocks: Block[] = []; - const lines = input.split('\\n'); - -+ // Handle empty input -+ if (lines.length === 0) return blocks; -+ - for (const line of lines) { - if (line.startsWith('#')) { - blocks.push({ type: 'heading', content: line }); -@@ -25,7 +27,7 @@ export function parseMarkdown(input: string): Block[] { - } - - export function formatBlock(block: Block): string { -- return block.content; -+ return block.content.trim(); - } - - // New helper function -diff --git a/src/components/App.tsx b/src/components/App.tsx -index 7654321..fedcba9 100644 ---- a/src/components/App.tsx -+++ b/src/components/App.tsx -@@ -1,5 +1,6 @@ - import React, { useState, useEffect } from 'react'; - import { parseMarkdown } from '../utils/parser'; -+import { formatBlock } from '../utils/parser'; - - export function App() { - const [blocks, setBlocks] = useState([]); -@@ -15,6 +16,10 @@ export function App() { - fetchData(); - }, []); - -+ const handleFormat = (block: Block) => { -+ return formatBlock(block); -+ }; -+ - return ( -
-

Plannotator

-@@ -22,7 +27,7 @@ export function App() { - {blocks.map((block, i) => ( -
- {block.type} -- {block.content} -+ {handleFormat(block)} -
- ))} -
-diff --git a/package.json b/package.json -index 1111111..2222222 100644 ---- a/package.json -+++ b/package.json -@@ -5,7 +5,8 @@ - "scripts": { - "dev": "vite", - "build": "vite build", -- "test": "vitest" -+ "test": "vitest", -+ "lint": "eslint src/" - }, - "dependencies": { - "react": "^18.2.0" -`; - -console.error("Starting Code Review server with OpenCode origin..."); - -const server = await startReviewServer({ - rawPatch: sampleDiff, - gitRef: "working tree", - origin: "opencode", - htmlContent: html as unknown as string, - onReady: (url, isRemote, port) => handleReviewServerReady(url, isRemote, port), -}); - -const result = await server.waitForDecision(); -await Bun.sleep(1500); -server.stop(); - -console.log(JSON.stringify(result, null, 2)); -process.exit(0); diff --git a/tests/manual/test-server.ts b/tests/manual/test-server.ts deleted file mode 100644 index b8e2bf769..000000000 --- a/tests/manual/test-server.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Test script for Plannotator server - * - * Usage: - * bun run tests/manual/test-server.ts [origin] - * - * Examples: - * bun run tests/manual/test-server.ts # defaults to claude-code - * bun run tests/manual/test-server.ts opencode # tests opencode origin - * - * Reads plan from stdin if provided, otherwise uses a sample plan. - */ - -import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; - -// @ts-ignore - Bun import attribute for text -import html from "../../apps/hook/dist/index.html" with { type: "text" }; - -const origin = process.argv[2] || "claude-code"; -const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled"; - -// Use sample plan (stdin reading was blocking) -const plan = `# Test Plan: Sample Feature - -## Overview -This is a sample plan for testing the Plannotator UI. - -## Implementation - -\`\`\`typescript -function hello() { - console.log("Hello, world!"); -} -\`\`\` - -## Checklist -- [ ] Step 1 -- [ ] Step 2 -- [x] Step 3 -`; - -console.error(`Starting Plannotator server with origin: ${origin}`); - -const server = await startPlannotatorServer({ - plan, - origin, - sharingEnabled, - htmlContent: html as unknown as string, - onReady: (url, isRemote, port) => handleServerReady(url, isRemote, port), -}); - -const result = await server.waitForDecision(); -await Bun.sleep(1500); -server.stop(); - -console.log(JSON.stringify(result, null, 2)); -process.exit(0); diff --git a/tests/manual/test-worktree-review.ts b/tests/manual/test-worktree-review.ts deleted file mode 100644 index ea7cdbec3..000000000 --- a/tests/manual/test-worktree-review.ts +++ /dev/null @@ -1,1152 +0,0 @@ -/** - * Test script for worktree support and expandable diff context in Code Review - * - * Creates a temporary git repo with multiple worktrees, each with different - * kinds of changes, then launches the review server so you can manually test - * the worktree dropdown, diff switching, and expandable context features. - * - * Usage: - * bun run tests/manual/test-worktree-review.ts [--keep] - * - * Options: - * --keep Don't clean up the temp repo on exit (for debugging) - * - * What to test: - * - * WORKTREE FEATURES: - * 1. Context dropdown appears above View dropdown, listing available worktrees - * 2. Selecting a worktree in Context switches files; pill gets highlighted border - * 3. View dropdown (Uncommitted/Last commit/vs main) stays the same in any context - * 4. Can switch directly between worktrees without going "back to main" first - * 5. Selecting main branch in Context restores the main repo view - * 6. Empty worktree shows appropriate empty state messages - * 7. Detached HEAD worktree uses directory name as label - * - * EXPANDABLE DIFF CONTEXT: - * 8. service-registry.ts — 4 disjoint hunks with gaps of 10-30 lines between them. - * Each gap shows "N unmodified lines" separator with expand up/down/both buttons. - * 9. Expand up/down reveals 100 lines at a time; small gaps (<100 lines) show - * a single "expand all" button instead. - * 10. Top of file (above first hunk) and bottom of file (below last hunk) are expandable. - * 11. deprecated-helper.ts — a deleted file. Should show expansion above hunks only - * (newContent is null, so newLines = []). - * 12. string-utils.ts → text-utils.ts — a renamed file. Expansion should use - * oldPath for old content and filePath for new content. - * 13. event-emitter.ts — a brand-new file with only additions. Expansion above - * the single hunk only (oldContent is null, so oldLines = []). - * 14. Switching diff types (uncommitted → last-commit → vs main) re-fetches - * file contents for expansion — verify separators appear in all modes. - * 15. Split and unified views both show expansion separators. - */ - -import { $ } from "bun"; -import { tmpdir } from "os"; -import path from "path"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { getGitContext, runGitDiff } from "@plannotator/server/git"; - -// @ts-ignore - Bun import attribute for text -import html from "../../apps/review/dist/index.html" with { type: "text" }; - -const KEEP = process.argv.includes("--keep"); - -// --- Load fixtures --- -const FIXTURES_DIR = path.join(import.meta.dir, "fixtures"); -const fixture = (name: string) => Bun.file(path.join(FIXTURES_DIR, name)).text(); - -// --- Setup temp repo with worktrees --- - -const sandbox = path.join(tmpdir(), `plannotator-wt-test-${Date.now()}`); -const mainRepo = path.join(sandbox, "main-repo"); - -console.error("=== Worktree Review Test ==="); -console.error(""); -console.error(`Sandbox: ${sandbox}`); -console.error(""); - -// Create main repo with initial content -await $`mkdir -p ${mainRepo}`.quiet(); -await $`git init`.quiet().cwd(mainRepo); -await $`git checkout -b main`.quiet().cwd(mainRepo); - -// Initial commit — a realistic small TypeScript project -// Large files loaded from fixtures, small ones inlined -const files: Record = { - "src/index.ts": [ - `import { App } from './app';`, - `import { loadConfig } from './config';`, - ``, - `const config = loadConfig();`, - `const app = new App(config);`, - `app.start();`, - ``, - ].join("\n"), - "src/app.ts": [ - `import type { Config } from './config';`, - `import { Router } from './router';`, - `import { Logger } from './utils/logger';`, - ``, - `export class App {`, - ` private router: Router;`, - ` private logger: Logger;`, - ``, - ` constructor(private config: Config) {`, - ` this.logger = new Logger(config.logLevel);`, - ` this.router = new Router();`, - ` }`, - ``, - ` start() {`, - ` this.logger.info('Starting server...');`, - ` this.router.register('GET', '/', (req) => ({ status: 200, body: 'OK' }));`, - ` this.router.register('GET', '/health', (req) => ({ status: 200, body: 'healthy' }));`, - ` this.logger.info(\`Server running on port \${this.config.port}\`);`, - ` }`, - `}`, - ``, - ].join("\n"), - "src/config.ts": [ - `export interface Config {`, - ` port: number;`, - ` logLevel: 'debug' | 'info' | 'warn' | 'error';`, - ` dbUrl: string;`, - ` maxConnections: number;`, - `}`, - ``, - `export function loadConfig(): Config {`, - ` return {`, - ` port: parseInt(process.env.PORT || '3000'),`, - ` logLevel: (process.env.LOG_LEVEL as Config['logLevel']) || 'info',`, - ` dbUrl: process.env.DATABASE_URL || 'postgres://localhost:5432/app',`, - ` maxConnections: parseInt(process.env.MAX_CONNECTIONS || '10'),`, - ` };`, - `}`, - ``, - ].join("\n"), - "src/router.ts": [ - `export interface Route {`, - ` method: string;`, - ` path: string;`, - ` handler: (req: Request) => Response | { status: number; body: string };`, - `}`, - ``, - `export class Router {`, - ` private routes: Route[] = [];`, - ``, - ` register(method: string, path: string, handler: Route['handler']) {`, - ` this.routes.push({ method, path, handler });`, - ` }`, - ``, - ` match(method: string, path: string): Route | undefined {`, - ` return this.routes.find(r => r.method === method && r.path === path);`, - ` }`, - ``, - ` list(): Route[] {`, - ` return [...this.routes];`, - ` }`, - `}`, - ``, - ].join("\n"), - "src/utils/parser.ts": [ - `export interface ParseResult {`, - ` lines: string[];`, - ` lineCount: number;`, - ` isEmpty: boolean;`, - `}`, - ``, - `export function parse(input: string): ParseResult {`, - ` const lines = input.split('\\n');`, - ` return {`, - ` lines,`, - ` lineCount: lines.length,`, - ` isEmpty: lines.every(l => l.trim() === ''),`, - ` };`, - `}`, - ``, - `export function parseJSON(input: string): T | null {`, - ` try {`, - ` return JSON.parse(input);`, - ` } catch {`, - ` return null;`, - ` }`, - `}`, - ``, - ].join("\n"), - "src/utils/format.ts": [ - `export function formatDate(date: Date): string {`, - ` return date.toISOString().split('T')[0];`, - `}`, - ``, - `export function formatBytes(bytes: number): string {`, - ` if (bytes === 0) return '0 B';`, - ` const units = ['B', 'KB', 'MB', 'GB'];`, - ` const i = Math.floor(Math.log(bytes) / Math.log(1024));`, - ` return \`\${(bytes / Math.pow(1024, i)).toFixed(1)} \${units[i]}\`;`, - `}`, - ``, - `export function slugify(text: string): string {`, - ` return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');`, - `}`, - ``, - ].join("\n"), - "src/utils/logger.ts": [ - `const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;`, - `type LogLevel = keyof typeof LEVELS;`, - ``, - `export class Logger {`, - ` constructor(private level: LogLevel = 'info') {}`, - ``, - ` private log(level: LogLevel, message: string, data?: unknown) {`, - ` if (LEVELS[level] >= LEVELS[this.level]) {`, - ` const timestamp = new Date().toISOString();`, - ` console.log(\`[\${timestamp}] [\${level.toUpperCase()}] \${message}\`, data ?? '');`, - ` }`, - ` }`, - ``, - ` debug(msg: string, data?: unknown) { this.log('debug', msg, data); }`, - ` info(msg: string, data?: unknown) { this.log('info', msg, data); }`, - ` warn(msg: string, data?: unknown) { this.log('warn', msg, data); }`, - ` error(msg: string, data?: unknown) { this.log('error', msg, data); }`, - `}`, - ``, - ].join("\n"), - "src/db/connection.ts": [ - `import type { Config } from '../config';`, - ``, - `export interface DBConnection {`, - ` query(sql: string, params?: unknown[]): Promise;`, - ` execute(sql: string, params?: unknown[]): Promise<{ affectedRows: number }>;`, - ` close(): Promise;`, - `}`, - ``, - `export async function createConnection(config: Config): Promise {`, - ` console.log(\`Connecting to \${config.dbUrl}...\`);`, - ` // Simulated connection`, - ` return {`, - ` async query(sql: string, params?: unknown[]): Promise {`, - ` return [];`, - ` },`, - ` async execute(sql: string, params?: unknown[]) {`, - ` return { affectedRows: 0 };`, - ` },`, - ` async close() {`, - ` console.log('Connection closed');`, - ` },`, - ` };`, - `}`, - ``, - ].join("\n"), - "src/db/migrations.ts": [ - `import type { DBConnection } from './connection';`, - ``, - `interface Migration {`, - ` version: number;`, - ` name: string;`, - ` up: string;`, - ` down: string;`, - `}`, - ``, - `const migrations: Migration[] = [`, - ` {`, - ` version: 1,`, - ` name: 'create_users',`, - ` up: 'CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW())',`, - ` down: 'DROP TABLE users',`, - ` },`, - ` {`, - ` version: 2,`, - ` name: 'create_posts',`, - ` up: 'CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT, body TEXT, created_at TIMESTAMP DEFAULT NOW())',`, - ` down: 'DROP TABLE posts',`, - ` },`, - `];`, - ``, - `export async function runMigrations(db: DBConnection): Promise {`, - ` for (const m of migrations) {`, - ` await db.execute(m.up);`, - ` console.log(\`Migration \${m.version}: \${m.name}\`);`, - ` }`, - `}`, - ``, - ].join("\n"), - "package.json": [ - `{`, - ` "name": "acme-api",`, - ` "version": "1.0.0",`, - ` "type": "module",`, - ` "scripts": {`, - ` "dev": "bun run --watch src/index.ts",`, - ` "build": "bun build src/index.ts --outdir dist",`, - ` "test": "bun test",`, - ` "lint": "eslint src/"`, - ` },`, - ` "dependencies": {`, - ` "pg": "^8.11.0"`, - ` },`, - ` "devDependencies": {`, - ` "typescript": "^5.3.0",`, - ` "@types/pg": "^8.10.0",`, - ` "eslint": "^8.50.0"`, - ` }`, - `}`, - ``, - ].join("\n"), - "tsconfig.json": [ - `{`, - ` "compilerOptions": {`, - ` "target": "ES2022",`, - ` "module": "ESNext",`, - ` "moduleResolution": "bundler",`, - ` "strict": true,`, - ` "outDir": "dist",`, - ` "rootDir": "src",`, - ` "skipLibCheck": true`, - ` },`, - ` "include": ["src/**/*.ts"]`, - `}`, - ``, - ].join("\n"), - "README.md": [ - `# Acme API`, - ``, - `A REST API for the Acme platform.`, - ``, - `## Getting Started`, - ``, - `\`\`\`bash`, - `bun install`, - `bun run dev`, - `\`\`\``, - ``, - `## Architecture`, - ``, - `- \`src/app.ts\` — Main application class`, - `- \`src/router.ts\` — HTTP routing`, - `- \`src/db/\` — Database layer (connection, migrations)`, - `- \`src/utils/\` — Shared utilities (logging, parsing, formatting)`, - ``, - ].join("\n"), - // Large files from fixtures — test expandable diff context - "src/services/registry.ts": await fixture("service-registry.ts"), - "src/utils/deprecated-helper.ts": await fixture("deprecated-helper.ts"), - "src/utils/string-utils.ts": await fixture("string-utils.ts"), -}; - -for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(mainRepo, filePath); - await $`mkdir -p ${path.dirname(fullPath)}`.quiet(); - await Bun.write(fullPath, content); -} - -await $`git add -A`.quiet().cwd(mainRepo); -await $`git commit -m "initial commit"`.quiet().cwd(mainRepo); - -// Make uncommitted changes in main repo — a few small edits across files -await Bun.write( - path.join(mainRepo, "src/index.ts"), - [ - `import { App } from './app';`, - `import { loadConfig } from './config';`, - `import { Logger } from './utils/logger';`, - ``, - `const config = loadConfig();`, - `const logger = new Logger(config.logLevel);`, - `const app = new App(config);`, - ``, - `logger.info('Booting application...');`, - `app.start();`, - `logger.info('Application started successfully');`, - ``, - ].join("\n"), -); -await Bun.write( - path.join(mainRepo, "src/utils/format.ts"), - [ - `export function formatDate(date: Date): string {`, - ` return date.toISOString().split('T')[0];`, - `}`, - ``, - `export function formatBytes(bytes: number): string {`, - ` if (bytes < 0) throw new Error('bytes must be non-negative');`, - ` if (bytes === 0) return '0 B';`, - ` const units = ['B', 'KB', 'MB', 'GB', 'TB'];`, - ` const i = Math.floor(Math.log(bytes) / Math.log(1024));`, - ` return \`\${(bytes / Math.pow(1024, i)).toFixed(1)} \${units[i]}\`;`, - `}`, - ``, - `export function slugify(text: string): string {`, - ` return text`, - ` .toLowerCase()`, - ` .replace(/[^a-z0-9]+/g, '-')`, - ` .replace(/(^-|-$)/g, '');`, - `}`, - ``, - `export function truncate(text: string, maxLength: number): string {`, - ` if (text.length <= maxLength) return text;`, - ` return text.slice(0, maxLength - 3) + '...';`, - `}`, - ``, - ].join("\n"), -); - -// Disjoint hunks: service-registry.ts with 4 scattered edits (from fixture) -await Bun.write( - path.join(mainRepo, "src/services/registry.ts"), - await fixture("service-registry-modified.ts"), -); - -// Deleted file: deprecated-helper.ts removed entirely -await $`git rm src/utils/deprecated-helper.ts`.quiet().cwd(mainRepo); - -// Renamed file: string-utils.ts → text-utils.ts with additions -await $`git mv src/utils/string-utils.ts src/utils/text-utils.ts`.quiet().cwd(mainRepo); -await Bun.write( - path.join(mainRepo, "src/utils/text-utils.ts"), - await fixture("text-utils.ts"), -); - -// New file (only additions): event-emitter.ts — brand new with no old version -await $`mkdir -p ${path.join(mainRepo, "src/events")}`.quiet(); -await Bun.write( - path.join(mainRepo, "src/events/emitter.ts"), - await fixture("event-emitter.ts"), -); - -console.error("Created main repo with uncommitted changes:") -console.error(" - src/index.ts — small edits (2 hunks)") -console.error(" - src/utils/format.ts — modified + new function") -console.error(" - src/services/registry.ts — 4 disjoint hunks (expansion test)") -console.error(" - src/utils/deprecated-helper.ts — deleted file") -console.error(" - src/utils/string-utils.ts → text-utils.ts — renamed + additions") -console.error(" - src/events/emitter.ts — brand new file"); - -// --- Worktree 1: feature-auth --- -// Tests the basic case: a new untracked file (auth.ts) and a modified tracked -// file (app.ts). Both should appear in the diff when this worktree is selected, -// exercising both `git diff HEAD` and `getUntrackedFileDiffs()` with cwd. -const wt1 = path.join(sandbox, "wt-feature-auth"); -await $`git worktree add ${wt1} -b feature-auth`.quiet().cwd(mainRepo); - -// New file: full auth module with JWT + password hashing -await Bun.write( - path.join(wt1, "src/auth/index.ts"), - [ - `export { authenticate, type AuthResult } from './middleware';`, - `export { hashPassword, verifyPassword } from './passwords';`, - `export { generateToken, verifyToken, type TokenPayload } from './tokens';`, - `export { createUser, findUserByEmail, type User } from './users';`, - ``, - ].join("\n"), -); -await Bun.write( - path.join(wt1, "src/auth/passwords.ts"), - [ - `const SALT_ROUNDS = 12;`, - ``, - `export async function hashPassword(password: string): Promise {`, - ` // In production, use bcrypt or argon2`, - ` const encoder = new TextEncoder();`, - ` const data = encoder.encode(password + SALT_ROUNDS);`, - ` const hash = await crypto.subtle.digest('SHA-256', data);`, - ` return Array.from(new Uint8Array(hash))`, - ` .map(b => b.toString(16).padStart(2, '0'))`, - ` .join('');`, - `}`, - ``, - `export async function verifyPassword(password: string, hash: string): Promise {`, - ` const computed = await hashPassword(password);`, - ` return computed === hash;`, - `}`, - ``, - ].join("\n"), -); -await Bun.write( - path.join(wt1, "src/auth/tokens.ts"), - [ - `export interface TokenPayload {`, - ` userId: number;`, - ` email: string;`, - ` iat: number;`, - ` exp: number;`, - `}`, - ``, - `const SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';`, - `const TOKEN_TTL = 60 * 60 * 24; // 24 hours`, - ``, - `export function generateToken(userId: number, email: string): string {`, - ` const payload: TokenPayload = {`, - ` userId,`, - ` email,`, - ` iat: Math.floor(Date.now() / 1000),`, - ` exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,`, - ` };`, - ` // Simplified — in production use a proper JWT library`, - ` return btoa(JSON.stringify(payload));`, - `}`, - ``, - `export function verifyToken(token: string): TokenPayload | null {`, - ` try {`, - ` const payload = JSON.parse(atob(token)) as TokenPayload;`, - ` if (payload.exp < Math.floor(Date.now() / 1000)) {`, - ` return null; // Expired`, - ` }`, - ` return payload;`, - ` } catch {`, - ` return null;`, - ` }`, - `}`, - ``, - ].join("\n"), -); -await Bun.write( - path.join(wt1, "src/auth/middleware.ts"), - [ - `import { verifyToken, type TokenPayload } from './tokens';`, - ``, - `export interface AuthResult {`, - ` authenticated: boolean;`, - ` user?: TokenPayload;`, - ` error?: string;`, - `}`, - ``, - `export function authenticate(req: Request): AuthResult {`, - ` const authHeader = req.headers.get('Authorization');`, - ` if (!authHeader) {`, - ` return { authenticated: false, error: 'Missing Authorization header' };`, - ` }`, - ``, - ` const [scheme, token] = authHeader.split(' ');`, - ` if (scheme !== 'Bearer' || !token) {`, - ` return { authenticated: false, error: 'Invalid Authorization format. Expected: Bearer ' };`, - ` }`, - ``, - ` const payload = verifyToken(token);`, - ` if (!payload) {`, - ` return { authenticated: false, error: 'Invalid or expired token' };`, - ` }`, - ``, - ` return { authenticated: true, user: payload };`, - `}`, - ``, - ].join("\n"), -); -await Bun.write( - path.join(wt1, "src/auth/users.ts"), - [ - `import type { DBConnection } from '../db/connection';`, - `import { hashPassword } from './passwords';`, - ``, - `export interface User {`, - ` id: number;`, - ` email: string;`, - ` passwordHash: string;`, - ` createdAt: Date;`, - `}`, - ``, - `export async function createUser(db: DBConnection, email: string, password: string): Promise {`, - ` const passwordHash = await hashPassword(password);`, - ` const result = await db.query(`, - ` 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',`, - ` [email, passwordHash],`, - ` );`, - ` return result[0];`, - `}`, - ``, - `export async function findUserByEmail(db: DBConnection, email: string): Promise {`, - ` const result = await db.query(`, - ` 'SELECT * FROM users WHERE email = $1',`, - ` [email],`, - ` );`, - ` return result[0] ?? null;`, - `}`, - ``, - ].join("\n"), -); - -// Modified: app.ts — wire up auth routes -await Bun.write( - path.join(wt1, "src/app.ts"), - [ - `import type { Config } from './config';`, - `import { Router } from './router';`, - `import { Logger } from './utils/logger';`, - `import { authenticate } from './auth';`, - `import { generateToken } from './auth/tokens';`, - `import { findUserByEmail, createUser } from './auth/users';`, - `import { verifyPassword } from './auth/passwords';`, - `import { createConnection, type DBConnection } from './db/connection';`, - ``, - `export class App {`, - ` private router: Router;`, - ` private logger: Logger;`, - ` private db!: DBConnection;`, - ``, - ` constructor(private config: Config) {`, - ` this.logger = new Logger(config.logLevel);`, - ` this.router = new Router();`, - ` }`, - ``, - ` async start() {`, - ` this.db = await createConnection(this.config);`, - ` this.logger.info('Database connected');`, - ``, - ` // Public routes`, - ` this.router.register('GET', '/', (req) => ({ status: 200, body: 'OK' }));`, - ` this.router.register('GET', '/health', (req) => ({ status: 200, body: 'healthy' }));`, - ``, - ` // Auth routes`, - ` this.router.register('POST', '/auth/register', async (req) => {`, - ` const { email, password } = await req.json() as { email: string; password: string };`, - ` const user = await createUser(this.db, email, password);`, - ` const token = generateToken(user.id, user.email);`, - ` return { status: 201, body: JSON.stringify({ token }) };`, - ` });`, - ``, - ` this.router.register('POST', '/auth/login', async (req) => {`, - ` const { email, password } = await req.json() as { email: string; password: string };`, - ` const user = await findUserByEmail(this.db, email);`, - ` if (!user || !(await verifyPassword(password, user.passwordHash))) {`, - ` return { status: 401, body: JSON.stringify({ error: 'Invalid credentials' }) };`, - ` }`, - ` const token = generateToken(user.id, user.email);`, - ` return { status: 200, body: JSON.stringify({ token }) };`, - ` });`, - ``, - ` // Protected route example`, - ` this.router.register('GET', '/api/me', (req) => {`, - ` const auth = authenticate(req);`, - ` if (!auth.authenticated) {`, - ` return { status: 401, body: JSON.stringify({ error: auth.error }) };`, - ` }`, - ` return { status: 200, body: JSON.stringify({ user: auth.user }) };`, - ` });`, - ``, - ` this.logger.info(\`Server running on port \${this.config.port}\`);`, - ` }`, - `}`, - ``, - ].join("\n"), -); - -// Modified: add password_hash column to users migration -await Bun.write( - path.join(wt1, "src/db/migrations.ts"), - [ - `import type { DBConnection } from './connection';`, - ``, - `interface Migration {`, - ` version: number;`, - ` name: string;`, - ` up: string;`, - ` down: string;`, - `}`, - ``, - `const migrations: Migration[] = [`, - ` {`, - ` version: 1,`, - ` name: 'create_users',`, - ` up: 'CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW())',`, - ` down: 'DROP TABLE users',`, - ` },`, - ` {`, - ` version: 2,`, - ` name: 'create_posts',`, - ` up: 'CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT, body TEXT, created_at TIMESTAMP DEFAULT NOW())',`, - ` down: 'DROP TABLE posts',`, - ` },`, - ` {`, - ` version: 3,`, - ` name: 'create_sessions',`, - ` up: 'CREATE TABLE sessions (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id), token TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT NOW())',`, - ` down: 'DROP TABLE sessions',`, - ` },`, - `];`, - ``, - `export async function runMigrations(db: DBConnection): Promise {`, - ` for (const m of migrations) {`, - ` await db.execute(m.up);`, - ` console.log(\`Migration \${m.version}: \${m.name}\`);`, - ` }`, - `}`, - ``, - `export async function rollbackMigration(db: DBConnection, version: number): Promise {`, - ` const migration = migrations.find(m => m.version === version);`, - ` if (!migration) throw new Error(\`Migration \${version} not found\`);`, - ` await db.execute(migration.down);`, - ` console.log(\`Rolled back migration \${version}: \${migration.name}\`);`, - `}`, - ``, - ].join("\n"), -); - -// Commit some changes so "Last commit" has content in this worktree -await $`git add -A`.quiet().cwd(wt1); -await $`git commit -m "feat: add authentication module"`.quiet().cwd(wt1); - -// Add one more uncommitted change on top so both "Uncommitted" and "Last commit" have content -await Bun.write( - path.join(wt1, "src/auth/rate-limit.ts"), - [ - `const WINDOW_MS = 60 * 1000;`, - `const MAX_ATTEMPTS = 5;`, - ``, - `const attempts = new Map();`, - ``, - `export function checkRateLimit(key: string): boolean {`, - ` const now = Date.now();`, - ` const entry = attempts.get(key);`, - ` if (!entry || now > entry.resetAt) {`, - ` attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });`, - ` return true;`, - ` }`, - ` entry.count++;`, - ` return entry.count <= MAX_ATTEMPTS;`, - `}`, - ``, - ].join("\n"), -); - -console.error("Created worktree: feature-auth (committed + 1 uncommitted file)"); - -// --- Worktree 2: fix-parser --- -// Tests that untracked files (validator.ts) show up alongside tracked changes -// (parser.ts). The untracked file is never `git add`-ed, so it exercises the -// `git ls-files --others` → `git diff --no-index` path in getUntrackedFileDiffs. -const wt2 = path.join(sandbox, "wt-fix-parser"); -await $`git worktree add ${wt2} -b fix-parser`.quiet().cwd(mainRepo); - -// Modified: parser.ts — fix empty input bug, add CSV/URL parsing -await Bun.write( - path.join(wt2, "src/utils/parser.ts"), - [ - `export interface ParseResult {`, - ` lines: string[];`, - ` lineCount: number;`, - ` isEmpty: boolean;`, - `}`, - ``, - `export function parse(input: string): ParseResult {`, - ` if (!input || input.trim() === '') {`, - ` return { lines: [], lineCount: 0, isEmpty: true };`, - ` }`, - ` const lines = input.split('\\n').filter(line => line.length > 0);`, - ` return {`, - ` lines,`, - ` lineCount: lines.length,`, - ` isEmpty: lines.length === 0,`, - ` };`, - `}`, - ``, - `export function parseJSON(input: string): T | null {`, - ` try {`, - ` return JSON.parse(input);`, - ` } catch {`, - ` return null;`, - ` }`, - `}`, - ``, - `export function parseCSV(input: string): string[][] {`, - ` const { lines } = parse(input);`, - ` return lines.map(line => {`, - ` const fields: string[] = [];`, - ` let current = '';`, - ` let inQuotes = false;`, - ` for (const char of line) {`, - ` if (char === '"') {`, - ` inQuotes = !inQuotes;`, - ` } else if (char === ',' && !inQuotes) {`, - ` fields.push(current.trim());`, - ` current = '';`, - ` } else {`, - ` current += char;`, - ` }`, - ` }`, - ` fields.push(current.trim());`, - ` return fields;`, - ` });`, - `}`, - ``, - `export function parseURL(url: string): { host: string; path: string; params: Record } | null {`, - ` try {`, - ` const parsed = new URL(url);`, - ` const params: Record = {};`, - ` parsed.searchParams.forEach((v, k) => { params[k] = v; });`, - ` return { host: parsed.host, path: parsed.pathname, params };`, - ` } catch {`, - ` return null;`, - ` }`, - `}`, - ``, - ].join("\n"), -); - -// Modified: logger.ts — add structured logging and log levels -await Bun.write( - path.join(wt2, "src/utils/logger.ts"), - [ - `const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;`, - `type LogLevel = keyof typeof LEVELS;`, - ``, - `interface LogEntry {`, - ` timestamp: string;`, - ` level: LogLevel;`, - ` message: string;`, - ` data?: unknown;`, - ` context?: string;`, - `}`, - ``, - `export class Logger {`, - ` private context?: string;`, - ``, - ` constructor(private level: LogLevel = 'info', context?: string) {`, - ` this.context = context;`, - ` }`, - ``, - ` child(context: string): Logger {`, - ` return new Logger(this.level, this.context ? \`\${this.context}.\${context}\` : context);`, - ` }`, - ``, - ` private log(level: LogLevel, message: string, data?: unknown) {`, - ` if (LEVELS[level] < LEVELS[this.level]) return;`, - ``, - ` const entry: LogEntry = {`, - ` timestamp: new Date().toISOString(),`, - ` level,`, - ` message,`, - ` ...(data !== undefined && { data }),`, - ` ...(this.context && { context: this.context }),`, - ` };`, - ``, - ` if (level === 'error') {`, - ` console.error(JSON.stringify(entry));`, - ` } else {`, - ` console.log(JSON.stringify(entry));`, - ` }`, - ` }`, - ``, - ` debug(msg: string, data?: unknown) { this.log('debug', msg, data); }`, - ` info(msg: string, data?: unknown) { this.log('info', msg, data); }`, - ` warn(msg: string, data?: unknown) { this.log('warn', msg, data); }`, - ` error(msg: string, data?: unknown) { this.log('error', msg, data); }`, - `}`, - ``, - ].join("\n"), -); - -// Untracked new file (never git-added) — exercises getUntrackedFileDiffs -await Bun.write( - path.join(wt2, "src/utils/validator.ts"), - [ - `export interface ValidationResult {`, - ` valid: boolean;`, - ` errors: string[];`, - `}`, - ``, - `export function validateEmail(email: string): ValidationResult {`, - ` const errors: string[] = [];`, - ` if (!email) errors.push('Email is required');`, - ` if (!email.includes('@')) errors.push('Email must contain @');`, - ` if (email.length > 254) errors.push('Email too long');`, - ` return { valid: errors.length === 0, errors };`, - `}`, - ``, - `export function validatePassword(password: string): ValidationResult {`, - ` const errors: string[] = [];`, - ` if (password.length < 8) errors.push('Password must be at least 8 characters');`, - ` if (!/[A-Z]/.test(password)) errors.push('Password must contain an uppercase letter');`, - ` if (!/[0-9]/.test(password)) errors.push('Password must contain a number');`, - ` return { valid: errors.length === 0, errors };`, - `}`, - ``, - `export function validatePort(port: number): ValidationResult {`, - ` const errors: string[] = [];`, - ` if (!Number.isInteger(port)) errors.push('Port must be an integer');`, - ` if (port < 1 || port > 65535) errors.push('Port must be between 1 and 65535');`, - ` return { valid: errors.length === 0, errors };`, - `}`, - ``, - ].join("\n"), -); - -// Another untracked new file -await Bun.write( - path.join(wt2, "src/utils/retry.ts"), - [ - `interface RetryOptions {`, - ` maxAttempts: number;`, - ` delayMs: number;`, - ` backoff: 'linear' | 'exponential';`, - `}`, - ``, - `const defaults: RetryOptions = {`, - ` maxAttempts: 3,`, - ` delayMs: 1000,`, - ` backoff: 'exponential',`, - `};`, - ``, - `export async function retry(`, - ` fn: () => Promise,`, - ` options: Partial = {},`, - `): Promise {`, - ` const opts = { ...defaults, ...options };`, - ` let lastError: Error | undefined;`, - ``, - ` for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {`, - ` try {`, - ` return await fn();`, - ` } catch (err) {`, - ` lastError = err instanceof Error ? err : new Error(String(err));`, - ` if (attempt < opts.maxAttempts) {`, - ` const delay = opts.backoff === 'exponential'`, - ` ? opts.delayMs * Math.pow(2, attempt - 1)`, - ` : opts.delayMs * attempt;`, - ` await new Promise(resolve => setTimeout(resolve, delay));`, - ` }`, - ` }`, - ` }`, - ``, - ` throw lastError;`, - `}`, - ``, - ].join("\n"), -); - -// Commit tracked changes so "Last commit" and "vs main" have content. -// The untracked files (validator.ts, retry.ts) stay uncommitted. -await $`git add src/utils/parser.ts src/utils/logger.ts`.quiet().cwd(wt2); -await $`git commit -m "fix: handle empty input and add CSV/URL parsing"`.quiet().cwd(wt2); - -console.error("Created worktree: fix-parser (2 committed + 2 untracked files)"); - -// --- Worktree 3: empty worktree --- -// Tests the empty state. No files are modified, so selecting this worktree -// should show "No uncommitted changes in this worktree." in the UI. -const wt3 = path.join(sandbox, "wt-empty"); -await $`git worktree add ${wt3} -b empty-branch`.quiet().cwd(mainRepo); - -console.error("Created worktree: empty-branch (no changes — tests empty state)"); - -// --- Worktree 4: detached HEAD --- -// Tests the label fallback. When a worktree has no branch (detached HEAD), -// getWorktrees() returns branch=null and the dropdown label should use -// the directory basename ("wt-detached") instead of a branch name. -const wt4 = path.join(sandbox, "wt-detached"); -const headSha = (await $`git rev-parse HEAD`.quiet().cwd(mainRepo)).text().trim(); -await $`git worktree add --detach ${wt4} ${headSha}`.quiet().cwd(mainRepo); - -// Hotfix: patch the router to handle 404s and add request logging -await Bun.write( - path.join(wt4, "src/router.ts"), - [ - `export interface Route {`, - ` method: string;`, - ` path: string;`, - ` handler: (req: Request) => Response | { status: number; body: string } | Promise;`, - `}`, - ``, - `export class Router {`, - ` private routes: Route[] = [];`, - ` private middleware: ((req: Request) => void)[] = [];`, - ``, - ` use(fn: (req: Request) => void) {`, - ` this.middleware.push(fn);`, - ` }`, - ``, - ` register(method: string, path: string, handler: Route['handler']) {`, - ` this.routes.push({ method, path, handler });`, - ` }`, - ``, - ` match(method: string, path: string): Route | undefined {`, - ` return this.routes.find(r => r.method === method && r.path === path);`, - ` }`, - ``, - ` async handle(req: Request): Promise<{ status: number; body: string }> {`, - ` const url = new URL(req.url);`, - ``, - ` // Run middleware`, - ` for (const fn of this.middleware) {`, - ` fn(req);`, - ` }`, - ``, - ` const route = this.match(req.method, url.pathname);`, - ` if (!route) {`, - ` return { status: 404, body: JSON.stringify({ error: 'Not Found', path: url.pathname }) };`, - ` }`, - ``, - ` try {`, - ` const result = await route.handler(req);`, - ` if (result instanceof Response) {`, - ` return { status: result.status, body: await result.text() };`, - ` }`, - ` return result;`, - ` } catch (err) {`, - ` const message = err instanceof Error ? err.message : 'Internal Server Error';`, - ` return { status: 500, body: JSON.stringify({ error: message }) };`, - ` }`, - ` }`, - ``, - ` list(): Route[] {`, - ` return [...this.routes];`, - ` }`, - `}`, - ``, - ].join("\n"), -); - -// Hotfix: connection pool with retry logic -await Bun.write( - path.join(wt4, "src/db/connection.ts"), - [ - `import type { Config } from '../config';`, - ``, - `export interface DBConnection {`, - ` query(sql: string, params?: unknown[]): Promise;`, - ` execute(sql: string, params?: unknown[]): Promise<{ affectedRows: number }>;`, - ` close(): Promise;`, - ` isConnected(): boolean;`, - `}`, - ``, - `const MAX_RETRIES = 3;`, - `const RETRY_DELAY = 1000;`, - ``, - `export async function createConnection(config: Config): Promise {`, - ` let connected = false;`, - ` let retries = 0;`, - ``, - ` while (!connected && retries < MAX_RETRIES) {`, - ` try {`, - ` console.log(\`Connecting to \${config.dbUrl} (attempt \${retries + 1}/\${MAX_RETRIES})...\`);`, - ` // Simulated connection attempt`, - ` connected = true;`, - ` } catch (err) {`, - ` retries++;`, - ` if (retries < MAX_RETRIES) {`, - ` console.warn(\`Connection failed, retrying in \${RETRY_DELAY}ms...\`);`, - ` await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));`, - ` } else {`, - ` throw new Error(\`Failed to connect after \${MAX_RETRIES} attempts: \${err}\`);`, - ` }`, - ` }`, - ` }`, - ``, - ` return {`, - ` async query(sql: string, params?: unknown[]): Promise {`, - ` if (!connected) throw new Error('Not connected');`, - ` return [];`, - ` },`, - ` async execute(sql: string, params?: unknown[]) {`, - ` if (!connected) throw new Error('Not connected');`, - ` return { affectedRows: 0 };`, - ` },`, - ` async close() {`, - ` connected = false;`, - ` console.log('Connection closed');`, - ` },`, - ` isConnected() {`, - ` return connected;`, - ` },`, - ` };`, - `}`, - ``, - ].join("\n"), -); - -// Commit the hotfix so "Last commit" and "vs main" have content -await $`git add -A`.quiet().cwd(wt4); -await $`git commit -m "hotfix: add 404 handling and connection retry logic"`.quiet().cwd(wt4); - -console.error("Created worktree: detached HEAD (2 committed files — hotfix)"); -console.error(""); - -// --- Run the review server from the main repo directory --- - -// Change process.cwd so git commands run in the sandbox main repo -process.chdir(mainRepo); - -const gitContext = await getGitContext(); -const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff( - "uncommitted", - gitContext.defaultBranch -); - -console.error("Git context discovered:"); -console.error(` Current branch: ${gitContext.currentBranch}`); -console.error(` Default branch: ${gitContext.defaultBranch}`); -console.error(` Diff options: ${gitContext.diffOptions.map(o => o.label).join(', ')}`); -if (gitContext.worktrees.length > 0) { - console.error(` Worktrees:`); - for (const wt of gitContext.worktrees) { - console.error(` ${wt.branch || wt.path.split('/').pop()} (${wt.path})`); - } -} -console.error(""); - -console.error("Starting review server..."); -console.error("Browser should open automatically."); -console.error(""); -console.error("=== WORKTREE TESTS ==="); -console.error(" 1. 'Context' dropdown appears above 'View' dropdown listing worktrees"); -console.error(" 2. Select 'feature-auth' in Context → highlighted pill, files update"); -console.error(" 3. 'Uncommitted' in View shows rate-limit.ts"); -console.error(" 4. 'Last commit' in View shows committed auth module (5 files)"); -console.error(" 5. Switch Context back to main branch → restores main repo files"); -console.error(" 6. Switch directly between worktrees without returning to main"); -console.error(" 7. 'empty-branch' context → all View options show empty state"); -console.error(" 8. Detached HEAD worktree uses directory name as label"); -console.error(""); -console.error("=== EXPANDABLE DIFF CONTEXT TESTS ==="); -console.error(" 8. registry.ts — 4 disjoint hunks. Between each pair, you should see"); -console.error(" 'N unmodified lines' separators with expand up/down/both buttons."); -console.error(" 9. Click expand up/down — reveals lines incrementally."); -console.error(" Small gaps show a single 'expand all' button instead."); -console.error(" 10. Top-of-file (above first hunk) and bottom-of-file (below last hunk)"); -console.error(" should also be expandable."); -console.error(" 11. deprecated-helper.ts — DELETED file. Only old content for expansion."); -console.error(" 12. string-utils.ts → text-utils.ts — RENAMED. Old path used for old side."); -console.error(" 13. events/emitter.ts — NEW file. Only new content, expansion above hunk."); -console.error(" 14. Switch diff types (uncommitted → last-commit → vs main) — expansion works in all."); -console.error(" 15. Toggle split/unified — expansion separators appear in both views."); -console.error(""); - -const server = await startReviewServer({ - rawPatch, - gitRef, - error: diffError, - origin: "claude-code", - diffType: "uncommitted", - gitContext, - sharingEnabled: false, - htmlContent: html as unknown as string, - onReady: (url, isRemote, port) => handleReviewServerReady(url, isRemote, port), -}); - -const result = await server.waitForDecision(); -await Bun.sleep(1500); -server.stop(); - -console.error(""); -console.error("Feedback received:"); -console.log(JSON.stringify(result, null, 2)); - -// --- Cleanup --- - -if (!KEEP) { - console.error(""); - console.error("Cleaning up sandbox..."); - // Remove worktrees before deleting the directory - await $`git worktree remove ${wt1} --force`.quiet().cwd(mainRepo).nothrow(); - await $`git worktree remove ${wt2} --force`.quiet().cwd(mainRepo).nothrow(); - await $`git worktree remove ${wt3} --force`.quiet().cwd(mainRepo).nothrow(); - await $`git worktree remove ${wt4} --force`.quiet().cwd(mainRepo).nothrow(); - await $`rm -rf ${sandbox}`.quiet(); - console.error("Done."); -} else { - console.error(""); - console.error(`Sandbox kept at: ${sandbox}`); - console.error("To clean up manually:"); - console.error(` rm -rf ${sandbox}`); -} - -process.exit(0); diff --git a/tests/parity/route-parity.test.ts b/tests/parity/route-parity.test.ts index c64ad0f30..2da127d2b 100644 --- a/tests/parity/route-parity.test.ts +++ b/tests/parity/route-parity.test.ts @@ -1,12 +1,12 @@ /** - * Route Parity Test + * Runtime Route Ownership Test * - * Extracts all API routes from Bun and Pi server files and asserts - * they are identical per server (plan, review, annotate) plus shared - * delegated handlers (editor annotations, AI endpoints). + * The Bun server is now the only Plannotator UI server runtime. This test + * keeps coverage that the canonical server still exposes routes while proving + * Pi no longer ships a mirrored node:http route implementation. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; @@ -56,35 +56,29 @@ const pi = { review: join(ROOT, "apps/pi-extension/server/serverReview.ts"), annotate: join(ROOT, "apps/pi-extension/server/serverAnnotate.ts"), editorAnnotations: join(ROOT, "apps/pi-extension/server/annotations.ts"), + serverDir: join(ROOT, "apps/pi-extension/server"), + serverBarrel: join(ROOT, "apps/pi-extension/server.ts"), }; const aiEndpointsFile = join(ROOT, "packages/ai/endpoints.ts"); // --- Tests --- -describe("route parity: Bun ↔ Pi", () => { - test("plan server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.plan)); - const piRoutes = unique(extractInlineRoutes(pi.plan)); - expect(piRoutes).toEqual(bunRoutes); +describe("route ownership: Bun server only", () => { + test("canonical Bun route files still expose API routes", () => { + expect(unique(extractInlineRoutes(bun.plan)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.review)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.annotate)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.editorAnnotations)).length).toBeGreaterThan(0); }); - test("review server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.review)); - const piRoutes = unique(extractInlineRoutes(pi.review)); - expect(piRoutes).toEqual(bunRoutes); - }); - - test("annotate server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.annotate)); - const piRoutes = unique(extractInlineRoutes(pi.annotate)); - expect(piRoutes).toEqual(bunRoutes); - }); - - test("editor annotation routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.editorAnnotations)); - const piRoutes = unique(extractInlineRoutes(pi.editorAnnotations)); - expect(piRoutes).toEqual(bunRoutes); + test("Pi mirrored route files are absent", () => { + expect(existsSync(pi.serverDir)).toBe(false); + expect(existsSync(pi.serverBarrel)).toBe(false); + expect(existsSync(pi.plan)).toBe(false); + expect(existsSync(pi.review)).toBe(false); + expect(existsSync(pi.annotate)).toBe(false); + expect(existsSync(pi.editorAnnotations)).toBe(false); }); test("AI endpoint keys are present (shared file)", () => { @@ -98,7 +92,7 @@ describe("route parity: Bun ↔ Pi", () => { expect(routes).toContain("/api/ai/sessions"); }); - test("all routes across all servers match", () => { + test("canonical Bun routes cover all server surfaces", () => { const bunAll = unique([ ...extractInlineRoutes(bun.plan), ...extractInlineRoutes(bun.review), @@ -107,14 +101,9 @@ describe("route parity: Bun ↔ Pi", () => { ...extractAIEndpointKeys(aiEndpointsFile), ]); - const piAll = unique([ - ...extractInlineRoutes(pi.plan), - ...extractInlineRoutes(pi.review), - ...extractInlineRoutes(pi.annotate), - ...extractInlineRoutes(pi.editorAnnotations), - ...extractAIEndpointKeys(aiEndpointsFile), - ]); - - expect(piAll).toEqual(bunAll); + expect(bunAll).toContain("/api/plan"); + expect(bunAll).toContain("/api/diff"); + expect(bunAll).toContain("/api/feedback"); + expect(bunAll).toContain("/api/ai/query"); }); }); diff --git a/tests/test-fixtures/12-gfm-and-inline-extras.md b/tests/test-fixtures/12-gfm-and-inline-extras.md index aaaa3bc82..f732a0934 100644 --- a/tests/test-fixtures/12-gfm-and-inline-extras.md +++ b/tests/test-fixtures/12-gfm-and-inline-extras.md @@ -119,7 +119,7 @@ Five flavors, matching Primer exactly: > Useful information that users should know, even when skimming content. Supports **bold**, `code`, and links like [the design doc](https://plannotator.ai/docs/alerts). > [!TIP] -> Helpful advice. Try running `bun run dev:hook` with a fixture at `tests/test-fixtures/12-gfm-and-inline-extras.md` to see this whole doc render live. +> Helpful advice. Try running `bun run dev:frontend` with a fixture at `tests/test-fixtures/12-gfm-and-inline-extras.md` to see this whole doc render live. > [!IMPORTANT] > Key information readers must know. Talk to @backnotprop before cherry-picking this into a point release — there's context in #538 that's not in the PR description.