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..ce0787204 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,31 @@ jobs: PLANNOTATOR_PORT="$port" "$@" & local pid=$! local ok=0 - + # Daemon control routes (/daemon/*) are open on localhost — no auth. 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 sessions + sessions="$(curl -sf "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 "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then + ok=1 + break + 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 +235,54 @@ jobs: -RedirectStandardError $stderr $ok = $false + # Daemon control routes (/daemon/*) are open on localhost — no auth. + $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 + $sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -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" -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: 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 +291,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 +447,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 +513,6 @@ jobs: needs: - build - smoke-binaries - - pi-extension-ai-runtime-windows - install-script-smoke runs-on: ubuntu-latest permissions: @@ -544,7 +535,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..d860b39a5 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. @@ -208,9 +223,9 @@ OpenCode/Pi: event handler intercepts command ↓ Input type detected: .md/.mdx → file read from disk - .html/.htm → file read, converted to markdown via Turndown (or rendered as-is with --render-html) + .html/.htm → file read, rendered as-is full-width by default (or converted to markdown via Turndown with --markdown) https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina) - folder/ → file browser opened, files converted on demand + folder/ → file browser opened; .html rendered as-is per file, .md/.mdx shown directly (--markdown converts HTML) ↓ Annotate server starts (reuses plan editor HTML with mode:"annotate") ↓ @@ -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. @@ -475,7 +507,7 @@ interface SharePayload { g?: ShareableImage[]; // Global attachments d?: (string | null)[]; // diffContext per annotation, parallel to `a` s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a` - h?: string; // Raw HTML content (--render-html mode) + h?: string; // Raw HTML content (HTML render mode) r?: 'html'; // Render mode flag (omitted = markdown) } @@ -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..78e11890f --- /dev/null +++ b/apps/frontend/src/app/Layout.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef } 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 { useResizablePanel } from "@plannotator/ui/hooks/useResizablePanel"; +import { ResizeHandle } from "@plannotator/ui/components/ResizeHandle"; +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({ + sidebarResize, + closeSidebarRef, +}: { + sidebarResize: ReturnType; + closeSidebarRef: React.MutableRefObject<() => void>; +}) { + 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, setOpen: setSidebarOpen } = useSidebar(); + + // Bridge the snap-close handler (created outside the provider) to the + // sidebar's setter, which only exists in here. + useEffect(() => { + closeSidebarRef.current = () => setSidebarOpen(false); + }, [closeSidebarRef, setSidebarOpen]); + + 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); + }, []); + + // While drag-resizing the sidebar, suppress its width transition so it tracks + // the cursor 1:1 instead of trailing 200ms behind (see styles.css). + useEffect(() => { + const el = document.documentElement; + if (sidebarResize.isDragging) el.setAttribute("data-sidebar-resizing", ""); + else el.removeAttribute("data-sidebar-resizing"); + return () => el.removeAttribute("data-sidebar-resizing"); + }, [sidebarResize.isDragging]); + + return ( + <> + + + {/* Drag handle on the docked sidebar's right edge (open + desktop only). */} + {sidebarOpen && ( +
+ +
+ )} +
+
+ +
+ + {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 }); + // useSidebar() (which owns the close fn) only exists inside SidebarProvider, + // so the snap handler can't reach it from here. LayoutContent fills this ref; + // the snap handler calls through it. + const closeSidebarRef = useRef<() => void>(() => {}); + const sidebarResize = useResizablePanel({ + storageKey: "plannotator-app-sidebar-width", + defaultWidth: 256, // 16rem + minWidth: 220, + maxWidth: 480, + side: "left", + // Drag the sidebar skinny → snap it shut (matches the in-plan panels). + onSnapClose: () => closeSidebarRef.current(), + // Render-free drag: write the live width straight to a :root CSS var. The + // whole layout (sidebar, sessions) never re-renders mid-drag. React only + // commits to state on release. SidebarProvider's --sidebar-width references + // this var, so React re-renders can't clobber the imperative value. + apply: (w) => { + document.documentElement.style.setProperty("--app-sidebar-width", `${w}px`); + }, + }); + + 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/components/landing/ActiveSessionRow.tsx b/apps/frontend/src/components/landing/ActiveSessionRow.tsx new file mode 100644 index 000000000..cb293087d --- /dev/null +++ b/apps/frontend/src/components/landing/ActiveSessionRow.tsx @@ -0,0 +1,32 @@ +import { Link } from "@tanstack/react-router"; +import { cn } from "@/lib/utils"; +import type { SessionSummary } from "../../daemon/contracts"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import { ROW, pad } from "../sidebar/row-style"; + +/** + * Flat (depth-0) live-session row reusing the sidebar row look. Unlike the + * sidebar's `SessionRow`, this is not tied to a tree depth or `useMatchRoute`; + * the conjoined view renders a flat list of sessions. + */ +export function ActiveSessionRow({ session }: { session: SessionSummary }) { + const meta = getSessionModeMeta(session.mode); + const Icon = meta.icon; + const label = formatSessionLabel(session.label, session.mode); + return ( + + + + {label} + + {meta.label} + + + ); +} diff --git a/apps/frontend/src/components/landing/ActiveSessionsList.tsx b/apps/frontend/src/components/landing/ActiveSessionsList.tsx new file mode 100644 index 000000000..2abf4fee2 --- /dev/null +++ b/apps/frontend/src/components/landing/ActiveSessionsList.tsx @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { compareSessionsByRecency } from "../../shared/session-sort"; +import { ActiveSessionRow } from "./ActiveSessionRow"; + +/** + * Landing-page list of sessions: just the list. Active sessions sit on top, most + * recently ended after. Unfiltered — the History page owns the Active/All toggle + * and the project filter. The "History →" link lives on the section heading in + * LandingPage (outside this box), so the box stays a plain list. + */ +export function ActiveSessionsList() { + const sessions = useDaemonEventStore((s) => s.sessions); + const sorted = useMemo(() => [...sessions].sort(compareSessionsByRecency), [sessions]); + + return ( +
+ {sorted.length > 0 ? ( + sorted.map((session) => ) + ) : ( +
No active sessions
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx new file mode 100644 index 000000000..e4cf1a759 --- /dev/null +++ b/apps/frontend/src/components/landing/AddProjectDialog.tsx @@ -0,0 +1,219 @@ +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 } 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 addProject = useProjectStore((s) => s.addProject); + const inputRef = useRef(null); + const listRef = useRef(null); + + 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) => { + if (e.key === "ArrowDown" && dirs.length > 0) { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % dirs.length); + } else if (e.key === "ArrowUp" && dirs.length > 0) { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + dirs.length) % dirs.length); + } else if (e.key === "Tab" && !e.shiftKey && dirs.length > 0) { + e.preventDefault(); + const target = activeIndex >= 0 && activeIndex < dirs.length ? dirs[activeIndex] : dirs[0]; + handleNavigate(target.path); + } else if (e.key === "Enter") { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < dirs.length) { + handleSelect(dirs[activeIndex].path); + } else if (resolvedPath) { + handleSelect(resolvedPath); + } + } + // Escape is handled by Radix Dialog (onEscapeKeyDown → onOpenChange(false)). + }, + [activeIndex, dirs, 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…} + +
+ +
+
+ {dirs.map((dir, i) => ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(i)} + /> + ))} + {!loading && dirs.length === 0 && ( +
+ No directories found +
+ )} +
+
+ +
+ + select + + + Tab navigate into + + + Esc close + +
+
+
+ ); +} + +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/FullSessionsHistoryView.tsx b/apps/frontend/src/components/landing/FullSessionsHistoryView.tsx new file mode 100644 index 000000000..462a46878 --- /dev/null +++ b/apps/frontend/src/components/landing/FullSessionsHistoryView.tsx @@ -0,0 +1,153 @@ +import { useMemo, useState } from "react"; +import { Folder } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { useProjectStore } from "../../stores/project-store"; +import { useHistoryStore } from "../../stores/history-store"; +import { useConjoinedHistory } from "./use-conjoined-history"; +import { ActiveSessionRow } from "./ActiveSessionRow"; +import { HistoryRow } from "./HistoryRow"; +import { PRGroup } from "./git-dashboard/PRGroup"; +import { compareSessionsByRecency } from "../../shared/session-sort"; + +interface FullSessionsHistoryViewProps { + active: boolean; + onBack: () => void; +} + +/** + * Full-screen conjoined Sessions + History browse. Carousel slide 2, mirroring + * the Git Dashboard full-page pattern (container, Back button, grouped rows). + */ +export function FullSessionsHistoryView({ active, onBack }: FullSessionsHistoryViewProps) { + const sessions = useDaemonEventStore((s) => s.sessions); + const projects = useProjectStore((s) => s.projects); + const entries = useHistoryStore((s) => s.entries); + const historyLoading = useHistoryStore((s) => s.loading); + + // Reached via the landing's "History →" link, so default to history; the Active + // tab stays available for a grouped/filtered view of live sessions. + const [activeOrAll, setActiveOrAll] = useState<"active" | "all">("all"); + const [projectFilter, setProjectFilter] = useState(null); + + useConjoinedHistory(active && activeOrAll === "all", projectFilter); + + const topLevel = useMemo(() => projects.filter((p) => !p.parentCwd), [projects]); + const nameToCwd = useMemo(() => { + const map: Record = {}; + for (const p of topLevel) map[p.name] = p.cwd; + return map; + }, [topLevel]); + + const filteredSessions = useMemo(() => { + const cwd = projectFilter ? nameToCwd[projectFilter] : null; + const base = cwd ? sessions.filter((s) => (s.projectCwd ?? s.cwd) === cwd) : sessions; + // Live sessions first, then most recent — groups inherit this order below. + return [...base].sort(compareSessionsByRecency); + }, [sessions, projectFilter, nameToCwd]); + + const filteredEntries = useMemo(() => { + const base = projectFilter ? entries.filter((e) => e.project === projectFilter) : entries; + // Newest plan first (by latest version mtime). + return [...base].sort((a, b) => (a.latest < b.latest ? 1 : a.latest > b.latest ? -1 : 0)); + }, [entries, projectFilter]); + + // Group entries / sessions by project name for PRGroup-style sections. + const entryGroups = useMemo(() => groupBy(filteredEntries, (e) => e.project), [filteredEntries]); + const sessionGroups = useMemo( + () => groupBy(filteredSessions, (s) => s.project), + [filteredSessions], + ); + + const isAll = activeOrAll === "all"; + const isEmpty = isAll ? filteredEntries.length === 0 : filteredSessions.length === 0; + + return ( +
+
+
+ +
+ +
+ setActiveOrAll(v as "active" | "all")}> + + + Active + + + All + + + + +
+ + {isAll && historyLoading && isEmpty && ( +
Loading history…
+ )} + + {isEmpty && !(isAll && historyLoading) && ( +
+ {isAll ? "No history found across your projects" : "No active sessions"} +
+ )} + + {!isEmpty && isAll && ( +
+ {Object.entries(entryGroups).map(([project, groupEntries]) => ( + + {groupEntries.map((entry) => ( + + ))} + + ))} +
+ )} + + {!isEmpty && !isAll && ( +
+ {Object.entries(sessionGroups).map(([project, groupSessions]) => ( + + {groupSessions.map((session) => ( + + ))} + + ))} +
+ )} +
+
+ ); +} + +function groupBy(items: T[], key: (item: T) => string): Record { + const out: Record = {}; + for (const item of items) { + const k = key(item); + (out[k] ??= []).push(item); + } + return out; +} diff --git a/apps/frontend/src/components/landing/HistoryRow.tsx b/apps/frontend/src/components/landing/HistoryRow.tsx new file mode 100644 index 000000000..40087697d --- /dev/null +++ b/apps/frontend/src/components/landing/HistoryRow.tsx @@ -0,0 +1,125 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { FileClock } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { daemonApiClient } from "../../daemon/api/client"; +import type { HistoryIndexEntry } from "../../daemon/contracts"; +import { ROW, pad } from "../sidebar/row-style"; +import { formatRelativeTime } from "./git-dashboard/use-git-dashboard"; + +/** + * Turn a history slug (`my-plan-2026-05-29`) into a readable title by stripping + * the trailing date stamp and de-kebabing. + */ +export function prettySlug(slug: string): string { + const withoutDate = slug.replace(/-\d{4}-\d{2}-\d{2}$/, ""); + return withoutDate.replace(/-/g, " ") || slug; +} + +/** + * Derive the directory portion of an absolute file path. History markdown files + * always live in an existing directory, so this yields a valid cwd the daemon + * can resolve a project against even when the live project registry has no + * matching entry (project removed, or added under a custom name). + */ +function dirnameOf(filePath: string): string { + const sep = filePath.includes("\\") && !filePath.includes("/") ? "\\" : "/"; + const trimmed = filePath.replace(/[\\/]+$/, ""); + const idx = trimmed.lastIndexOf(sep); + if (idx <= 0) return trimmed.slice(0, idx + 1) || sep; + return trimmed.slice(0, idx); +} + +interface HistoryRowProps { + entry: HistoryIndexEntry; + /** Owning project cwd (name→cwd lookup). Empty string when unknown. */ + cwd: string; + density: "compact" | "full"; +} + +export function HistoryRow({ entry, cwd, density }: HistoryRowProps) { + const [launching, setLaunching] = useState(false); + const navigate = useNavigate(); + + const handleOpen = useCallback(async () => { + if (!entry.latestVersionPath) { + toast.error("This history entry has no readable plan file"); + return; + } + setLaunching(true); + // `cwd` is empty when the live project registry has no entry for this + // history project (project removed, or registered under a custom name). + // Fall back to the directory of the (absolute) history file so the daemon + // always receives a resolvable cwd instead of throwing on an empty one. + const sessionCwd = cwd || dirnameOf(entry.latestVersionPath); + const result = await daemonApiClient.createAnnotateSession(sessionCwd, entry.latestVersionPath); + setLaunching(false); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to open plan", { description: result.error.message }); + } + }, [cwd, entry.latestVersionPath, navigate]); + + const title = prettySlug(entry.slug); + + if (density === "compact") { + return ( + + ); + } + + 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..7b9d8a724 --- /dev/null +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -0,0 +1,696 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + Code2, + FileText, + 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 { prettyPath } from "@plannotator/shared/project"; +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 { ActiveSessionsList } from "./ActiveSessionsList"; +import { FullSessionsHistoryView } from "./FullSessionsHistoryView"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import { buildStacks, type PRStack } from "./buildStacks"; +import type { + ProjectEntry, + PRListItem, + WorktreeEntry, +} from "../../daemon/contracts"; + +interface LandingPageProps { + onAddProject: () => void; +} + +export type LaunchFn = (cwd: string, action: "review" | "annotate", prUrl?: string) => void; + +export function LandingPage({ onAddProject }: LandingPageProps) { + const projects = useProjectStore((s) => s.projects); + const sessions = useDaemonEventStore((s) => s.sessions); + const [launching, setLaunching] = useState(false); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); + const navigate = useNavigate(); + + const launch = useCallback( + async (cwd, action, prUrl) => { + if (launching) return; + setLaunching(true); + try { + const result = + action === "annotate" + ? await daemonApiClient.createAnnotateFolderSession(cwd) + : await daemonApiClient.createReviewSession(cwd, prUrl); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to launch", { description: result.error.message }); + } + } finally { + setLaunching(false); + } + }, + [launching, navigate], + ); + + return ( +
+
+
+
+ +
+
+
+
+
+ + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( +
+ {projects.length > 0 && ( +
+
+ + Projects + +
+ + +
+
+ +
+ )} + + {(projects.length > 0 || sessions.length > 0) && ( +
+
+ + Active sessions + + +
+ +
+ )} + + {projects.length === 0 && ( + + )} +
+ )} +
+
+
+
+ setViewIndex(0)} /> +
+
+ setViewIndex(0)} /> +
+
+
+
+
+ ); +} + +function ProjectTable({ + projects, + launch, + launching, +}: { + projects: ProjectEntry[]; + launch: LaunchFn; + launching: boolean; +}) { + 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, + launch, + launching, +}: { + project: ProjectEntry; + children: ProjectEntry[]; + isFirst: boolean; + launch: LaunchFn; + launching: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [worktreesFetched, setWorktreesFetched] = useState(false); + const [prs, setPrs] = useState([]); + const [prPlatform, setPrPlatform] = useState(null); + 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); + setPrError(result.data.error ?? null); + } + setPrsLoading(false); + setPrsFetchedAt(Date.now()); + }); + }, [expanded, project.cwd, prsFetchedAt, prsLoading]); + + const hasWorktrees = hasChildren || worktrees.length > 0; + + 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 && ( +
+ + + + Worktrees + + + PRs + + + + + + + + + +
+ )} +
+
+ + + + + Remove project + + + +
+ ); +} + +function PRRow({ + pr, + projectCwd, + launch, + launching, +}: { + pr: PRListItem; + projectCwd: string; + launch: LaunchFn; + launching: boolean; +}) { + return ( + + ); +} + +function StackIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function StackGroup({ + stack, + projectCwd, + launch, + launching, +}: { + stack: PRStack; + projectCwd: string; + launch: LaunchFn; + launching: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {stack.prs.map((pr) => ( + + ))} +
+ )} +
+ ); +} + +function PRList({ + prs, + loading, + error, + platform, + projectCwd, + launch, + launching, +}: { + prs: PRListItem[]; + loading: boolean; + error: string | null; + platform: string | null; + projectCwd: string; + launch: LaunchFn; + launching: boolean; +}) { + 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, + launch, + launching, +}: { + children: ProjectEntry[]; + worktrees: WorktreeEntry[]; + hasWorktrees: boolean; + launch: LaunchFn; + launching: boolean; +}) { + 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) => ( +
+ + {wt.branch} + + {prettyPath(wt.path)} + +
+ + +
+
+ ))} +
+ ); +} + +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..5607c518d --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx @@ -0,0 +1,140 @@ +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 [projectFilter, setProjectFilter] = useState(null); + const { groups, metrics, loading, error, isEmpty, projectNames } = useGitDashboard( + active, + projectFilter, + ); + 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 ( +
+
+
+ + {projectNames.length > 0 && ( + + )} +
+ + {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..d627612fc --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx @@ -0,0 +1,103 @@ +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..29b9a2bf3 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts @@ -0,0 +1,105 @@ +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, projectFilter: string | null = null) { + 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]); + + // Project names that actually have PRs, for the filter dropdown. Derived from + // the full (unfiltered) set so the options stay stable while filtering. + const projectNames = useMemo(() => { + const names = new Set(); + for (const pr of prs) names.add(pr.projectName); + return [...names].sort((a, b) => a.localeCompare(b)); + }, [prs]); + + const filteredPRs = useMemo( + () => (projectFilter ? prs.filter((pr) => pr.projectName === projectFilter) : prs), + [prs, projectFilter], + ); + + const groups = useMemo(() => groupPRs(filteredPRs), [filteredPRs]); + const metrics = useMemo(() => computeMetrics(filteredPRs), [filteredPRs]); + + const isEmpty = + groups.open.length === 0 && groups.draft.length === 0 && groups.merged.length === 0; + + return { groups, metrics, loading, error, isEmpty, projectNames }; +} diff --git a/apps/frontend/src/components/landing/use-conjoined-history.ts b/apps/frontend/src/components/landing/use-conjoined-history.ts new file mode 100644 index 000000000..8db40a9a5 --- /dev/null +++ b/apps/frontend/src/components/landing/use-conjoined-history.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useHistoryStore } from "../../stores/history-store"; + +const STALE_MS = 30_000; + +/** + * Fetch history into `historyStore` whenever the conjoined view needs the "All" + * data. Mirrors `use-git-dashboard`'s staleness effect: refetch when active and + * either the data is stale or the project filter (by NAME) changed. + * + * @param active Whether the surface currently needs history (i.e. "All" mode + * and the view is visible). + * @param projectName The project-name filter (null/undefined = all projects). + */ +export function useConjoinedHistory(active: boolean, projectName: string | null) { + const loading = useHistoryStore((s) => s.loading); + const lastFetchedAt = useHistoryStore((s) => s.lastFetchedAt); + const lastProjectKey = useHistoryStore((s) => s.lastProjectKey); + const fetchHistory = useHistoryStore((s) => s.fetchHistory); + + useEffect(() => { + if (!active) return; + const key = projectName ?? ""; + const stale = + !lastFetchedAt || Date.now() - lastFetchedAt > STALE_MS || key !== lastProjectKey; + if (stale && !loading) { + void fetchHistory(projectName ?? undefined); + } + }, [active, projectName, lastFetchedAt, lastProjectKey, loading, fetchHistory]); +} diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx new file mode 100644 index 000000000..ce04d227a --- /dev/null +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -0,0 +1,40 @@ +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"; +// plan-review styles are folded into the shared design system +// (@plannotator/ui/design-system.css, imported via src/styles.css). +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..b71594edd --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,34 @@ +import { useAppStore } from "../../stores/app-store"; +import { SettingsDialog } from "@plannotator/ui/components/settings/SettingsDialog"; + +/** + * AppSettingsDialog — thin frontend adapter over the shared SettingsDialog. + * + * Reads the app-store for open state and the active session's mode/origin, then + * renders the shared dialog in daemon-backed mode (all tabs, server-synced). + * All dialog composition/behavior lives in @plannotator/ui — this only wires the + * frontend's Zustand store into the shared component's props. + */ +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + + const origin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + const mode = activeSessionId + ? (visitedSessions[activeSessionId]?.bootstrap?.session?.mode ?? null) + : null; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + + return ( + + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx new file mode 100644 index 000000000..dd7529c39 --- /dev/null +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -0,0 +1,387 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { toast } from "sonner"; +import { Folder, FolderOpen, Plus, Settings } from "lucide-react"; +import { daemonApiClient } from "../../daemon/api/client"; +import { TaterSpriteSidebar } from "@plannotator/ui/components/sprites"; +import { useActiveProjectCwd } from "./useActiveProjectCwd"; +import { ROW, pad } from "./row-style"; +import { appStore, useAppStore } from "../../stores/app-store"; +import { cn } from "@/lib/utils"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { buildSessionTree } from "@plannotator/ui/utils/sessionTree"; +import type { + SessionTreeProject, + SessionTreeWorktree, +} from "@plannotator/ui/utils/sessionTree"; +import type { DaemonSessionSummary } from "@plannotator/shared/daemon-protocol"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { useProjectStore } from "../../stores/project-store"; +import type { SessionSummary } from "../../daemon/contracts"; +import { formatSessionLabel, getSessionModeMeta } from "../../shared/session-meta"; + +/** Non-terminal session statuses — the only ones the sidebar surfaces. */ +const LIVE_STATUSES = new Set(["active", "idle", "awaiting-resubmission"]); + +function SessionRow({ + session, + depth, + matchRoute, +}: { + session: DaemonSessionSummary; + depth: number; + matchRoute: ReturnType; +}) { + const isActive = !!matchRoute({ + to: "/s/$sessionId", + params: { sessionId: session.id }, + }); + const Icon = getSessionModeMeta(session.mode).icon; + return ( + + + {formatSessionLabel(session.label, session.mode)} + + ); +} + +/** Mode icon shared with annotate session rows, so the row reads as a sibling. */ +const AnnotateModeIcon = getSessionModeMeta("annotate").icon; + +/** True for an annotate session whose match key encodes a folder (vs a single file). */ +function isFolderAnnotateSession(s: DaemonSessionSummary): boolean { + return s.mode === "annotate" && !!s.matchKey && s.matchKey.includes(":folder:"); +} + +/** The live folder-annotate session anchored to exactly this folder, if any. */ +function folderSessionFor( + sessions: DaemonSessionSummary[], + cwd: string, +): DaemonSessionSummary | undefined { + return sessions.find((s) => s.matchKey?.endsWith(`:folder:${cwd}`)); +} + +/** + * The folder's "Annotate" row — one per project and worktree, so every folder is + * openable. When the folder's annotate session is live it IS that session: the row + * highlights when active and navigates straight to it. When none exists yet, the + * row launches one (create-or-reuse via the daemon, mirroring HistoryRow.handleOpen). + */ +function FolderAnnotateRow({ + cwd, + depth, + session, + matchRoute, +}: { + cwd: string; + depth: number; + session?: DaemonSessionSummary; + matchRoute: ReturnType; +}) { + const navigate = useNavigate(); + const [launching, setLaunching] = useState(false); + const isActive = + !!session && !!matchRoute({ to: "/s/$sessionId", params: { sessionId: session.id } }); + + const handleOpen = async () => { + if (session) { + void navigate({ to: "/s/$sessionId", params: { sessionId: session.id } }); + return; + } + if (launching) return; + setLaunching(true); + const result = await daemonApiClient.createAnnotateFolderSession(cwd); + setLaunching(false); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to open folder", { description: result.error.message }); + } + }; + + return ( + + ); +} + +function WorktreeNode({ + worktree, + depth, + matchRoute, +}: { + worktree: SessionTreeWorktree; + depth: number; + matchRoute: ReturnType; +}) { + const override = useAppStore((s) => s.worktreeOpen[worktree.cwd]); + const setWorktreeOpen = useAppStore((s) => s.setWorktreeOpen); + // The folder-annotate session (if live) is represented by the Annotate row, not + // a separate session row. + const folderSession = folderSessionFor(worktree.sessions, worktree.cwd); + const sessionRows = worktree.sessions.filter((s) => !isFolderAnnotateSession(s)); + // Default open when the worktree has a real session (something beyond its + // Annotate row) OR contains the session you're currently viewing — so the + // active worktree opens itself, even after a refresh (the route is the source + // of truth). A user's explicit toggle overrides the default and sticks. + const containsActive = worktree.sessions.some( + (s) => !!matchRoute({ to: "/s/$sessionId", params: { sessionId: s.id } }), + ); + const open = override ?? (sessionRows.length > 0 || containsActive); + return ( + setWorktreeOpen(worktree.cwd, next)}> + + W + {worktree.name} + {sessionRows.length > 0 && ( + + {sessionRows.length} + + )} + + + + {sessionRows.map((session) => ( + + ))} + + + ); +} + +function ProjectNode({ + project, + isOpen, + onToggle, + matchRoute, +}: { + project: SessionTreeProject; + isOpen: boolean; + onToggle: () => void; + matchRoute: ReturnType; +}) { + // Folder-annotate sessions are shown as each folder's Annotate row, so they're + // excluded from session-row rendering and from the live-session count. + const folderSession = folderSessionFor(project.directSessions, project.cwd); + const directRows = project.directSessions.filter((s) => !isFolderAnnotateSession(s)); + const liveCount = + directRows.length + + project.worktrees.reduce( + (sum, wt) => sum + wt.sessions.filter((s) => !isFolderAnnotateSession(s)).length, + 0, + ); + + return ( + + + {isOpen ? ( + + ) : ( + + )} + {project.name} + {liveCount > 0 && ( + + {liveCount} + + )} + + + + {directRows.map((session) => ( + + ))} + {project.worktrees.map((worktree) => ( + + ))} + {liveCount === 0 && project.worktrees.length === 0 && !folderSession && ( +
+ No live sessions +
+ )} +
+
+ ); +} + +export function AppSidebarContent({ contentClassName }: { contentClassName?: string } = {}) { + const sessions = useDaemonEventStore((s) => s.sessions); + const projects = useProjectStore((p) => p.projects); + const expandedProjects = useAppStore((s) => s.expandedProjects); + const toggleProjectExpand = useAppStore((s) => s.toggleProjectExpand); + const activeProjectCwd = useActiveProjectCwd(); + const matchRoute = useMatchRoute(); + + // Live-only: exclude terminal sessions (completed/cancelled/expired/failed). + const liveSessions = useMemo( + () => sessions.filter((s) => LIVE_STATUSES.has(s.status)), + [sessions], + ); + + // buildSessionTree only reads project/worktree placement fields, never `mode`, + // so the (widened) SessionSummary.mode is safe to narrow at this boundary. + const tree = useMemo( + () => buildSessionTree(projects, liveSessions as DaemonSessionSummary[]), + [projects, liveSessions], + ); + + // Active project is open by default — seed it into expandedProjects exactly once + // per cwd (one-shot guard) so a later explicit collapse isn't re-opened. Effect + // depends only on activeProjectCwd, never on expandedProjects. + const seededProjects = useRef>(new Set()); + useEffect(() => { + if (activeProjectCwd && !seededProjects.current.has(activeProjectCwd)) { + seededProjects.current.add(activeProjectCwd); + appStore.getState().setProjectExpanded(activeProjectCwd, true); + } + }, [activeProjectCwd]); + + return ( + <> + + + +
+ + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + +
+ +
+ + + +
+ Projects +
+ {tree.length === 0 ? ( +
+ No projects yet +
+ ) : ( + tree.map((project) => ( + toggleProjectExpand(project.cwd)} + matchRoute={matchRoute} + /> + )) + )} +
+ + + + + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings + + + + + + ); +} + +export function AppSidebar() { + return ( + + {/* Logo/header stays pinned at the top; only the project tree drops down + a bit in the docked sidebar (the peek is fine, so it's untouched). */} + + + ); +} diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..75497482b --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +// Hover-intent: how long the cursor must rest on the left edge before the peek +// slides in. Keeps a quick brush past the edge from triggering it. Tune freely. +const SHOW_DELAY_MS = 600; +// Grace period before hiding when the cursor leaves the peek, so you don't lose +// it by cutting a corner. +const HIDE_DELAY_MS = 150; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const showTimeout = useRef | null>(null); + const hideTimeout = useRef | null>(null); + + const clearShow = useCallback(() => { + if (showTimeout.current) { + clearTimeout(showTimeout.current); + showTimeout.current = null; + } + }, []); + + const clearHide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + }, []); + + // Edge strip: arm a delayed reveal (hover intent). + const scheduleShow = useCallback(() => { + clearHide(); + if (visible) return; + clearShow(); + showTimeout.current = setTimeout(() => setVisible(true), SHOW_DELAY_MS); + }, [visible, clearHide, clearShow]); + + // Left the edge before the delay elapsed — cancel the pending reveal. + const cancelShow = useCallback(() => { + clearShow(); + }, [clearShow]); + + // On the peek itself: keep it open instantly (cancel any pending hide). + const keepOpen = useCallback(() => { + clearShow(); + clearHide(); + setVisible(true); + }, [clearShow, clearHide]); + + const hide = useCallback(() => { + clearShow(); + clearHide(); + hideTimeout.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS); + }, [clearShow, clearHide]); + + // Backdrop fade follows visibility. + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + // If the real sidebar opens, drop any pending peek state. + useEffect(() => { + if (open) { + clearShow(); + clearHide(); + setVisible(false); + } + }, [open, clearShow, clearHide]); + + // Clean up timers on unmount. + useEffect(() => () => { + if (showTimeout.current) clearTimeout(showTimeout.current); + if (hideTimeout.current) clearTimeout(hideTimeout.current); + }, []); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on the left edge (delayed reveal). + Matched to the peek's vertical band so the peek always opens directly + under the cursor (no dead zones above/below where it would open). */} +
+ {/* Backdrop overlay */} + {backdropMounted && ( +
+ )} + {/* Floating sidebar panel */} +
+
+ +
+
+ + ); +} diff --git a/apps/frontend/src/components/sidebar/row-style.ts b/apps/frontend/src/components/sidebar/row-style.ts new file mode 100644 index 000000000..78564775d --- /dev/null +++ b/apps/frontend/src/components/sidebar/row-style.ts @@ -0,0 +1,15 @@ +/** + * Shared sidebar row styling. Lifted out of `AppSidebar.tsx` so the conjoined + * sessions+history surface can render Active rows with the exact same look + * without coupling to the sidebar's tree-row component (which depends on + * `useMatchRoute` and `DaemonSessionSummary`). + */ + +/** One disclosure-width per nesting level; depth-based left pad is the only indent. */ +export const INDENT = 14; +export const pad = (depth: number) => ({ paddingLeft: `${8 + depth * INDENT}px` }); + +/** Shared compact row. ~26px tall, single-line, truncating. */ +export const ROW = + "group/row flex h-[30px] w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm " + + "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/50"; diff --git a/apps/frontend/src/components/sidebar/useActiveProjectCwd.test.ts b/apps/frontend/src/components/sidebar/useActiveProjectCwd.test.ts new file mode 100644 index 000000000..5fa368383 --- /dev/null +++ b/apps/frontend/src/components/sidebar/useActiveProjectCwd.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { activeProjectCwdOf } from "./useActiveProjectCwd"; +import type { SessionSummary } from "../../daemon/contracts"; + +function session(overrides: Partial & { id: string }): SessionSummary { + return { + mode: "plan", + status: "active", + url: "http://localhost/s/x", + project: "proj", + label: "plugin-plan-claude-code-proj-main", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("activeProjectCwdOf", () => { + test("returns null when no active session id", () => { + const sessions = [session({ id: "a", projectCwd: "/p" })]; + expect(activeProjectCwdOf(sessions, null)).toBeNull(); + }); + + test("returns null when active id matches no session", () => { + const sessions = [session({ id: "a", projectCwd: "/p" })]; + expect(activeProjectCwdOf(sessions, "missing")).toBeNull(); + }); + + test("prefers projectCwd of the active session", () => { + const sessions = [ + session({ id: "a", projectCwd: "/owner", cwd: "/worktree" }), + session({ id: "b", projectCwd: "/other" }), + ]; + expect(activeProjectCwdOf(sessions, "a")).toBe("/owner"); + }); + + test("falls back to cwd when projectCwd is absent (pre-migration row)", () => { + const sessions = [session({ id: "a", cwd: "/legacy-cwd" })]; + expect(activeProjectCwdOf(sessions, "a")).toBe("/legacy-cwd"); + }); + + test("returns null when active session has neither projectCwd nor cwd", () => { + const sessions = [session({ id: "a" })]; + expect(activeProjectCwdOf(sessions, "a")).toBeNull(); + }); + + test("selects the active session, not the first", () => { + const sessions = [ + session({ id: "a", projectCwd: "/a" }), + session({ id: "b", projectCwd: "/b" }), + ]; + expect(activeProjectCwdOf(sessions, "b")).toBe("/b"); + }); +}); diff --git a/apps/frontend/src/components/sidebar/useActiveProjectCwd.ts b/apps/frontend/src/components/sidebar/useActiveProjectCwd.ts new file mode 100644 index 000000000..afcde9aa8 --- /dev/null +++ b/apps/frontend/src/components/sidebar/useActiveProjectCwd.ts @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { useAppStore } from "../../stores/app-store"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import type { SessionSummary } from "../../daemon/contracts"; + +/** + * Owning-project key for a session, with cwd fallback for pre-migration rows. + * MUST match `owningProjectKey` in packages/ui/utils/sessionTree.ts so the + * sidebar's "active project" lines up with the tree node a session lands under. + */ +export function activeProjectCwdOf( + sessions: SessionSummary[], + activeSessionId: string | null, +): string | null { + if (!activeSessionId) return null; + const session = sessions.find((s) => s.id === activeSessionId); + if (!session) return null; + return session.projectCwd ?? session.cwd ?? null; +} + +/** + * Derive the cwd of the project that owns the currently-active session. + * Never stored — single source of truth is `activeSessionId` + live sessions. + */ +export function useActiveProjectCwd(): string | null { + const sessions = useDaemonEventStore((s) => s.sessions); + const activeSessionId = useAppStore((s) => s.activeSessionId); + return useMemo( + () => activeProjectCwdOf(sessions, activeSessionId), + [sessions, activeSessionId], + ); +} diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..e8b91c97c --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,4 @@ +// Canonical Button lives in @plannotator/ui so the shell and the embedded +// plan/review apps share one implementation. Re-exported here to preserve the +// frontend's "@/components/ui/button" import path. +export { Button, buttonVariants } from "@plannotator/ui/components/ui/button"; 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..5eddeb29e --- /dev/null +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,714 @@ +"use client"; + +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 { PanelLeftIcon } from "@plannotator/ui/components/icons/PanelLeftIcon"; + +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 ( + +
+ +
+ Projects + +
+ +
+ +
+
+ + +
+
plannotatorfeat/ui2-plan3
+

~/work/plannotator

+
+
+ + +
+
+
+
Worktrees · 2PRs · 4
+
+ feat/folder-annotate + ReviewAnnotate +
+
+ feat/ui2-code-review + ReviewAnnotate +
+
+
+ + +
+ + +
visual-explainermain

~/oss/visual-explainer

+
+
+
+ + +
cmark-gfmmaster

~/oss/cmark-gfm

+
+
+
+ +
+ +

C1 — list-cards + drill-down (refines C5)

+
+ + diff --git a/goals/landing-redesign/combo-2-drilldown-cards.html b/goals/landing-redesign/combo-2-drilldown-cards.html new file mode 100644 index 000000000..7a2b0d6ad --- /dev/null +++ b/goals/landing-redesign/combo-2-drilldown-cards.html @@ -0,0 +1,85 @@ + + + + + + C2 — Cards + drill-down + + + + + + +
+
+
P
+ Plannotator + History → + +
+
+ +
+

Projects

+
+ + +
+
+ +
+
plannotator3
+

~/work/plannotator · feat/ui2-plan

+
+ + + +
+ +
+
+ Worktrees + PRs · 4 +
+
+
+ feat/folder-annotate + ReviewAnnotate +
+
+ feat/ui2-code-review + ReviewAnnotate +
+
+
+
+ + +
+
+ +
visual-explainer

~/oss/visual-explainer

+ +
+
+
+
+ +
cmark-gfm

~/oss/cmark-gfm

+ +
+
+
+ +

Active sessions

+ +
+ + diff --git a/goals/landing-redesign/combo-3-refined-current.html b/goals/landing-redesign/combo-3-refined-current.html new file mode 100644 index 000000000..c343c9c15 --- /dev/null +++ b/goals/landing-redesign/combo-3-refined-current.html @@ -0,0 +1,66 @@ + + + + + + C3 — Refined current + + + + + + +
+
+
P
+ Plannotator + +
+ +
Select a project
+
+ + + + + +
+ + +
+ Launch on plannotator +
+ + +
+
+ +
+ Active sessions + History → +
+ +

C3 — refined current · select → launch

+
+ + diff --git a/goals/landing-redesign/combo-4-grid-drilldown.html b/goals/landing-redesign/combo-4-grid-drilldown.html new file mode 100644 index 000000000..a5f21e798 --- /dev/null +++ b/goals/landing-redesign/combo-4-grid-drilldown.html @@ -0,0 +1,74 @@ + + + + + + C4 — Card grid + drill-down (refines C2) + + + + + + +
+
+
P
+ Plannotator + + History → + +
+
+ +
+ + + +

Projects

+
+ + +
+
+
+
plannotator
+ 3 +
+

~/work/plannotator · feat/ui2-plan

+
+ + +
+
+
+
WorktreesPRs · 4
+
+
feat/folder-annotateReviewAnnotate
+
feat/ui2-code-reviewReviewAnnotate
+
+
+
+ + +
+
visual-explainer
+

~/oss/visual-explainer

+
+
+
+
cmark-gfm
+

~/oss/cmark-gfm

+
+
+
+
+ + diff --git a/goals/landing-redesign/combo-5-list-cards.html b/goals/landing-redesign/combo-5-list-cards.html new file mode 100644 index 000000000..c1bb33544 --- /dev/null +++ b/goals/landing-redesign/combo-5-list-cards.html @@ -0,0 +1,83 @@ + + + + + + C5 — List-cards + + + + + + +
+
+
P
+ Plannotator + +
+ +
+ Projects + +
+ +
+ +
+ +
+
plannotatorfeat/ui2-plan3
+

~/work/plannotator

+
+
+ + +
+ +
+ +
+ +
+
visual-explainermain
+

~/oss/visual-explainer

+
+
+ + +
+ +
+ +
+ +
+
cmark-gfmmaster
+

~/oss/cmark-gfm

+
+
+ + +
+ +
+
+ +
+ Active sessions + History → +
+ +

C5 — list-cards · smallest leap from current

+
+ + diff --git a/goals/landing-redesign/current.html b/goals/landing-redesign/current.html new file mode 100644 index 000000000..83f348e8c --- /dev/null +++ b/goals/landing-redesign/current.html @@ -0,0 +1,65 @@ + + + + + + Current (reference) + + + + + + +
+
+
P
+ Plannotator + +
+ +
Select project
+
+
+ + plannotator + feat/ui2-plan + ~/work/plannotator + +
+
+ + visual-explainer + ~/oss/visual-explainer + +
+
+ + cmark-gfm + ~/oss/cmark-gfm + +
+
+ +
Launch
+
+ + + +
+ +
+ Active sessions + History → +
+ +

Current — list + launch bar (baseline)

+
+ + diff --git a/goals/landing-redesign/index.html b/goals/landing-redesign/index.html new file mode 100644 index 000000000..6d509dd35 --- /dev/null +++ b/goals/landing-redesign/index.html @@ -0,0 +1,56 @@ + + + + + + Homepage — cards × current + + + + + +
+ +
+ +
+
+ + + + diff --git a/goals/merge-to-main/carry-in-inventory.md b/goals/merge-to-main/carry-in-inventory.md new file mode 100644 index 000000000..00f304ed6 --- /dev/null +++ b/goals/merge-to-main/carry-in-inventory.md @@ -0,0 +1,71 @@ +# Merge `main` → `feat/single-server-runtime`: Carry-In Inventory + +The checklist for merging `main` into the stack (PR #733) **without silently dropping main's work**. + +## Situation + +- **Fork point:** `82636e12` (2026-05-18). Since then: **main +35 commits, our branch +19.** +- The merge has two conflict shapes: (A) files we *deleted* that main *edited*, and (B) files we *both* edited. But the **real risk is invisible** — see the warning below. + +> [!CAUTION] +> **The dangerous carry-ins won't show up as merge conflicts.** Three of main's changes (#763, #795, #792) lived in code we **deleted or never had**, so `git merge` will complete "cleanly" and silently leave them out. A green merge ≠ a complete merge. This inventory is the guard against that. + +## Do NOT rename folders to ease the merge + +The old→new dirs are **replacements, not renames** (`packages/editor` ≠ `packages/plannotator-plan-review` — different code, same job). Renaming our new code into the old paths would convert clean "keep-deleted" resolutions into ~100 file-by-file content conflicts. The conflicts are not a naming problem. + +| Only on main | Only on our branch | +|---|---| +| `packages/editor`, `packages/review-editor`, `apps/review` | `apps/frontend`, `packages/plannotator-plan-review`, `packages/plannotator-code-review` | +| `apps/amp-plugin`, `apps/droid-plugin`, `apps/waitlist-service` (additive) | | + +--- + +## Class 1 — fix is in a file we KEPT → take main's hunk (all confirmed missing on our side) + +- [ ] **#805 `9b545d12`** — `apps/pi-extension/index.ts` (~L945): replace `setTimeout(() => pi.sendUserMessage("Continue with the approved plan."), 0)` with `pi.sendUserMessage("Continue with the approved plan.", { deliverAs: "followUp" })`. +- [ ] **#756 `42c85f0a`** — `packages/server/browser.ts`: add `NOOP_BROWSER_VALUES` + exported `isNoOpBrowserSentinel()`; rewrite `shouldTryRemoteBrowserFallback()` to treat sentinels (`true`/`false`/`none`/`:`/`0`/`1`) as unset; strip sentinels in `openBrowser()` before using `PLANNOTATOR_BROWSER`/`BROWSER`. + `browser.test.ts`. +- [ ] **#786 `5438f664`** — `apps/hook/server/session-log.ts`: resolve `DEFAULT_SESSIONS_DIR`/`DEFAULT_PROJECTS_DIR` from `CLAUDE_CONFIG_DIR` (fallback `~/.claude`); add `projectsDirOverride?` to `findSessionLogsByAncestorWalk` and thread it through. + test isolation. +- [ ] **#743 `29390c9e`** — `apps/opencode-plugin/index.ts`: `getPlanBackingPath(project)` → `~/.plannotator/active/{project}/_active-plan.md` (was `directory/.opencode/plans/`); add `homedir`/`sanitizeTag`/`unlinkSync` imports; `unlinkSync` the backing file on approval. + tests. +- [ ] **#752 `b3f1deb8`** — `apps/opencode-plugin/index.ts` `validateEdits`: `if (edit.end > lineCount)` → `if (edit.end > lineCount && lineCount > 0)`. + test. +- [ ] **#796 `92efe6fd`** — `packages/ui/utils/permissionMode.ts`: add `'auto'` to the `PermissionMode` union, the `PERMISSION_MODE_OPTIONS` array (label "Auto Mode"), and the comment. (UI iterates the array dynamically — no component changes.) + +## Class 2 — code we DELETED/REPLACED → hand-port (WON'T appear as conflicts) + +- [ ] **#763 `2a552869` — Ask AI in plan & annotate** *(largest)*. Infra in `packages/ai` is **already present** (`AIContextMode` has `plan-review`/`annotate`; `buildSystemPrompt` has both branches). Missing: + - **Server:** mount `createAIEndpoints` + `ProviderRegistry` + `SessionManager` and `/api/ai/*` in `packages/server/index.ts` (plan) and `packages/server/annotate.ts`, mirroring `packages/server/review.ts` (~L48, L356-396), with `mode: "plan-review"` / `"annotate"`. + - **Frontend:** build a shared document AI chat panel + `useAIChat` in `packages/ui` (adapt from `packages/plannotator-code-review/hooks/useAIChat.ts` + `AITab.tsx`); wire into `packages/plannotator-plan-review/App.tsx` + the annotate surface; add "Ask AI" to `packages/ui/components/CommentPopover.tsx` + `Viewer.tsx`; feed `aiProviders` from `/api/ai/capabilities` into `Settings`. + - Note: `AISetupDialog.tsx`/`AISettingsTab.tsx` on our branch are stale pre-#763 leftovers. +- [ ] **#795 `e0aee745` — `PLANNOTATOR_DATA_DIR`**. Recreate `packages/shared/data-dir.ts` (`getPlannotatorDataDir()` with `~` expansion + relative→absolute) and add `./data-dir` export to `packages/shared/package.json`. Replace hardcoded `join(homedir(), ".plannotator", …)` in: + - shared: `storage.ts` (6), `config.ts` (1 `CONFIG_DIR`), `draft.ts` (1), `improvement-hooks.ts` (2), `pr-gitlab.ts` (1) + - server: `browser.ts` (IPC registry), `codex-review.ts` (3), `sessions.ts` (1), `tour/tour-review.ts` (1) + - **daemon (new files #795 never saw):** `daemon/state.ts:76`, `daemon/session-store.ts:65` (`SNAPSHOT_DIR`), `daemon/project-registry.ts:12`, `daemon/server.ts:570` (`historyRoot`) — wire `getPlannotatorDataDir()` as the daemon `baseDir` default and thread it through. + - Re-apply the extension/vendor/install-script/docs bits as relevant. +- [ ] **#792 `7db5e9b8` — Windows Pi shim**. Add `packages/ai/providers/command-path.ts` (`resolveWindowsCommandShim`, `buildWindowsCommandScriptSpawnCommand`, `killWindowsProcessTree`, `resolveCommandFromWhichOutput`, `shouldSpawnViaShell`); reapply the `pi-sdk.ts` + `pi-sdk-node.ts` spawn/`handleProcessEnd`/`kill` diffs (both files still exist on our side); add `./providers/command-path` export to `packages/ai/package.json`; wrap the pi path in `packages/server/review.ts` (~L394) with `resolveWindowsCommandShim(Bun.which("pi"))`. + - `vendor.sh` part: **N/A** (our vendor.sh no longer vendors `packages/ai/providers/*`). CI Windows smoke job: **obsolete** (targeted the deleted Pi server). + +## Class 3 — new integrations (merge adds the dir; then adapt) + +- [ ] **#787 `4de62e83` — Droid**. Lands cleanly (new dir, never on our branch; thin-wrapper compatible). **DROP** `apps/droid-plugin/commands/plannotator-archive.js` + its references in `.factory-plugin/plugin.json`/README/marketplace (archive subcommand is removed). **TAKE** the droid entries in `packages/shared/agents.ts` / `config.ts` / `prompts.ts` (else the `droid` origin is unrecognized). The other commands (annotate/last/review) map to live subcommands. +- [ ] **#803 `8c947c54` (+ #810/#811/#812) — Amp**. Lands cleanly (new dir) but **won't work as-is**: Amp waits on `PLANNOTATOR_READY_FILE`, which our daemon never writes → local Amp sessions hang/error. Decide: (1) make the daemon write the ready-file on the direct-CLI path, or (2) refactor Amp onto our `plugin-client.ts`/`plugin-protocol.ts`. Verify its edits to `apps/hook/server/index.ts` + `packages/server/shared-handlers.ts` merge cleanly. (Amp treats runs as one-shot — doesn't use the persistent/`awaiting-resubmission` model; acceptable.) + +## Class 4 — noise → auto-merge, no action + +~18 commits: `/workspaces/` theming, the waitlist service (`apps/waitlist-service`), marketing/Nav/Turnstile/OSS-checkbox/copy tweaks, version bumps. `git merge` handles these; just confirm they land. + +--- + +## Suggested merge sequence + +1. **Work on a throwaway branch**, not the shared one: `git checkout -b merge/main-into-ssr feat/single-server-runtime` then `git merge origin/main`. +2. **Old folders** (delete/modify) → keep deleted: `git rm` `packages/editor`, `packages/review-editor`, `apps/pi-extension/server/*`. Their *intent* is captured in Class 2, not by keeping the files. +3. **Class-1 both-modified files** → take main's version / apply the listed hunks. +4. **Config/manifests/lock** (`package.json`, two `plugin.json`, `openpackage.yml`, opencode/pi `package.json`, `.gitignore`, `AGENTS.md`) → union both sides; **regenerate `bun.lock` with `bun install`** (don't hand-merge the lock). +5. **Amp / Droid / waitlist** come in additively → then apply the Droid drop-archive + shared droid entries; park Amp ready-file compat as a follow-up. +6. **Hand-port the three Class-2 items** (#763, #795, #792) — the part a clean merge silently skips. +7. `bun run typecheck` + `bun test` + frontend build. Then **manually verify** Ask-AI-in-plan/annotate, `PLANNOTATOR_DATA_DIR`, and Windows shim actually work. +8. Only after green: merge the reconciled result back and open/refresh PR #733. + +## Provenance + +Inventory produced by a 10-agent read-only analysis pass (one per substantive main commit), 2026-05-28. Class-1 verified via direct file comparison; Class-2/3 via commit-diff + new-architecture cross-reference. diff --git a/goals/performance/backlog/configstore-zustand-migration.md b/goals/performance/backlog/configstore-zustand-migration.md new file mode 100644 index 000000000..b34fde4de --- /dev/null +++ b/goals/performance/backlog/configstore-zustand-migration.md @@ -0,0 +1,131 @@ +# ConfigStore → Zustand Migration + +> **STATUS: DONE** — shipped in PR #808 (`12d7bd27`). Implemented as a `zustand/vanilla` store with selector-based subscriptions; `useConfigValue` API, cookie persistence, and debounced server sync unchanged. The notes below are the original scoping plan, kept for reference. + +## Problem + +The hand-rolled configStore broadcasts to ALL 63 subscribers when ANY setting changes. When a user changes their display name, 12 hidden code review components re-render to check if their diff style changed. With 5 sessions, that's 52 wasted re-renders from a single setting change. + +The configStore is a 129-line singleton with `Map` for values and `Set` for subscribers. The `notify()` method iterates through every listener on every write — no selectivity. `useSyncExternalStore` in the `useConfigValue` hook does compare snapshots, but the notification itself triggers the comparison in every subscriber. + +## Current Architecture + +``` +configStore.set('displayName', 'John') + → toCookie('John') // immediate + → pendingServerWrites.merge() // queue + → notify() // calls ALL 63 listeners + → 12 hidden review components run getSnapshot() + → 4 hidden plan components run getSnapshot() + → 11 active components run getSnapshot() + → React reconciles 63 components (most bail with same value) +``` + +## Target Architecture + +``` +configStore.set('displayName', 'John') + → Zustand set({ displayName: 'John' }) + → Zustand notifies only subscribers of displayName + → 2 components subscribed to displayName re-render + → 61 other components not notified at all +``` + +## What Stays the Same + +- **15 settings** with the same names, types, defaults +- **Cookie persistence** — immediate write on every change +- **Server sync** — debounced POST /api/config at 300ms +- **init(serverConfig)** — server values override cookies on session load +- **All consumer call sites** — `useConfigValue('diffStyle')` becomes `useConfigStore(s => s.diffStyle)`, same usage pattern + +## Implementation + +### Phase 1: Create Zustand store with middleware + +Replace `packages/ui/config/configStore.ts` (129 lines) with a Zustand store: + +```typescript +const useConfigStore = create()( + immer((set, get) => ({ + // 15 settings as flat properties + displayName: fromCookieOrDefault('displayName'), + diffStyle: fromCookieOrDefault('diffStyle'), + // ...etc + + set: (key, value) => { + set(state => { state[key] = value; }); + toCookie(key, value); + queueServerSync(key, value); + }, + + init: (serverConfig) => { + set(state => { + // Apply server overrides via immer + for (const [key, def] of entries) { + const val = def.fromServer(serverConfig); + if (val !== undefined) state[key] = val; + } + }); + }, + })) +); +``` + +The `queueServerSync` function maintains the same 300ms debounce + batch merge + `apiFetch('/api/config')` pattern. + +### Phase 2: Update useConfigValue hook + +```typescript +// Before: +export function useConfigValue(key: K) { + return useSyncExternalStore(configStore.subscribe, () => configStore.get(key)); +} + +// After: +export function useConfigValue(key: K) { + return useConfigStore(s => s[key]); +} +``` + +Same API, same return type. Consumers don't change their call signature. + +### Phase 3: Migrate 17 consumer files + +Mechanical find-and-replace: +- `configStore.set('diffStyle', v)` → `useConfigStore.getState().set('diffStyle', v)` (or keep `configStore.set` as an alias) +- `configStore.get('diffStyle')` → `useConfigStore.getState().diffStyle` +- `configStore.init(cfg)` → `useConfigStore.getState().init(cfg)` + +Most consumers only use `useConfigValue` (the hook) — those just need the import path updated. + +## Files to Touch + +**Core (rewrite):** +1. `packages/ui/config/configStore.ts` — full rewrite +2. `packages/ui/config/useConfig.ts` — simplify to Zustand selector +3. `packages/ui/config/index.ts` — update exports + +**Consumers (mechanical):** +4-20. 17 files across packages/ui/, plannotator-code-review/, plannotator-plan-review/, review-editor/, editor/ — change import + hook signature + +## Effort + +| Task | Time | +|------|------| +| Zustand store + cookie middleware + server sync | 2-3 hours | +| useConfigValue hook update | 15 minutes | +| Migrate 17 consumer files | 1 hour | +| Test cookie ↔ server ↔ init round-trip | 1-2 hours | +| **Total** | **5-7 hours** | + +## Risk + +Low. This is a state management swap with identical external behavior. All 15 settings, cookie persistence, server sync timing, and init() semantics remain the same. The only change is notification granularity — from broadcast-all to selector-based. Zustand is already a dependency in the frontend app (used by appStore, projectStore, eventStore). + +## Result + +- Setting change notifies only subscribers of that setting, not all 63 +- Hidden sessions with 12 subscriptions get zero wasted re-renders +- `immer` middleware eliminates the hand-rolled `deepMerge` helper +- Consistent with the rest of the frontend app (all stores are Zustand) diff --git a/goals/performance/backlog/global-keyboard-registry.md b/goals/performance/backlog/global-keyboard-registry.md new file mode 100644 index 000000000..db923f3cf --- /dev/null +++ b/goals/performance/backlog/global-keyboard-registry.md @@ -0,0 +1,104 @@ +# Global Keyboard Shortcut Registry + +## Problem + +Every session surface registers its own keyboard listeners on `window`. With 5 sessions mounted (4 hidden via keep-alive), every keystroke fires 32 handlers. Most bail out early via `isVisible()` checks, but the function call overhead accumulates and causes input lag. + +There are two categories of keyboard handlers: +- **38 bindings in the shortcut registry** (`packages/ui/shortcuts/`) — already structured, but each surface creates its own `useShortcutScope` listener on `window` +- **10 raw `window.addEventListener('keydown')` calls** — bypass the registry entirely, inlined in the two 2500-line App.tsx files + +## Current Architecture + +``` +Session A (visible) + ├── useShortcutScope('plan-editor') → window.addEventListener + ├── window.addEventListener('keydown') × 4 (raw) + └── window.addEventListener('paste') × 1 + +Session B (hidden) + ├── useShortcutScope('review-editor') → window.addEventListener + ├── window.addEventListener('keydown') × 6 (raw) + └── window.addEventListener('keyup') × 1 + +Session C (hidden) + └── ... same as above +``` + +Every keystroke → dispatches to ALL listeners across ALL sessions. + +## Target Architecture + +``` +App Shell (Layout.tsx) + └── ONE window.addEventListener('keydown') + ├── Checks activeSessionId + ├── Looks up active surface's scope + handlers + └── Dispatches to ONE surface only + +Session A (visible) + └── Registers handlers with the global registry (no window listener) + +Session B (hidden) + └── Handlers registered but never dispatched (not active) +``` + +Every keystroke → dispatches to ONE handler set. + +## Implementation + +### Phase 1: Global dispatcher in app shell + +Create `apps/frontend/src/keyboard/global-dispatcher.ts`: +- One `window.addEventListener('keydown')` registered in `Layout.tsx` +- Maintains a map of `sessionId → { scope, handlers }` +- On keydown: look up `activeSessionId`, dispatch to that session's handlers only +- Surfaces call `registerSessionShortcuts(sessionId, scope, handlers)` on mount and `unregister` on unmount + +### Phase 2: Move raw handlers into scopes + +The 10 raw handlers in the two App.tsx files need to become scope bindings: + +**Code Review (`plannotator-code-review/App.tsx`):** + +| Line | Keys | Complexity | +|------|------|-----------| +| 651 | Mod+Shift+T (dev tour) | Trivial | +| 759 | Mod+F, Enter/F3, Escape, Mod+B, Mod+. (search/nav) | Complex — 5 keys, state-dependent | +| 1099 | V, A (file viewed/stage) | Medium | +| 1710-1711 | Alt Alt (double-tap destination toggle) | Tricky — partially in registry | +| 1762 | Mod+Enter (approve/feedback) | Medium | + +**Plan Review (`plannotator-plan-review/App.tsx`):** + +| Line | Keys | Complexity | +|------|------|-----------| +| 322 | Escape (close plan diff) | Trivial | +| 941 | Paste (image handling) | Different event, stays raw | +| 1211 | Mod+Enter (approve/feedback) | Medium | +| 1553 | Mod+S (quick save) | Trivial | +| 1579 | Mod+P (print) | Trivial | + +### Phase 3: Remove per-surface window listeners + +Each surface stops calling `window.addEventListener` directly. Instead they pass their handlers to the global dispatcher via a prop or context. The `useShortcutScope` hook gets deprecated for the embedded case — surfaces use the global registry instead. + +## Effort + +- Phase 1 (global dispatcher): ~1 hour +- Phase 2 (move 10 handlers): ~2 hours +- Phase 3 (cleanup): ~30 minutes +- Testing: ~1 hour + +**Total: ~4-5 hours** + +## Risk + +Low. The shortcut engine's `dispatchShortcutEvent` already handles matching and preventDefault. We're changing WHERE it's called (app shell vs surface), not HOW. The paste handler stays raw (different event type). The double-tap Alt handler needs special attention since `useDoubleTapShortcuts` uses keyup, not keydown. + +## Result + +- 1 keyboard listener instead of 32 +- Zero handlers fire on hidden sessions +- All shortcuts centrally defined and discoverable +- The unified settings Shortcuts tab already shows all bindings — this makes the runtime match diff --git a/goals/performance/baseline-2026-05-20.md b/goals/performance/baseline-2026-05-20.md new file mode 100644 index 000000000..27aca8505 --- /dev/null +++ b/goals/performance/baseline-2026-05-20.md @@ -0,0 +1,86 @@ +# Performance Baseline — 2026-05-20 + +Captured via React DevTools Profiler on `feat/unified-settings` branch. Single code review session, normal usage (file switching, hovering, annotations, toolbar interactions). + +## Summary + +| Metric | Value | +|--------|-------| +| Total commits (re-renders) | 67 | +| Total render time | 8,815ms | +| Total fiber instances | ~19,000 | +| Full-tree re-renders (>15k fibers) | 29 commits (43%) | +| Full-tree render time | 4,825ms (55%) | +| Partial re-renders | 38 commits (57%) | +| Partial render time | 3,990ms (45%) | + +## Problem 1: ReviewApp Full-Tree Cascades — 55% of render time + +Every state change in the 2,550-line App.tsx (file switch, annotation click, hover, toolbar interaction) re-renders the entire component tree — all ~19,000 fibers. 29 commits triggered by ReviewApp, totaling 4,825ms. + +**Root cause:** 43 useState calls in one component, no memo boundaries, unstable callbacks that recreate on every interaction. + +**Fix:** Callback stabilization via `storeApi.getState()` reads, then migrate heavy children (FileTree, ReviewSidebar) to read from store directly. + +## Problem 2: FileTreeNodeItem — 14% of render time + +531 FileTreeNodeItem instances, each re-rendering 29 times during the session. 1,202ms total self-time. They re-render because FileTree re-renders (because App.tsx re-renders). No React.memo boundary. + +**Fix:** React.memo on FileTreeNodeItem, or FileTree reads from store directly (stops the cascade from App.tsx). + +## Problem 3: Per-File Radix Component Churn — 18% of render time + +426 instances each of DiffOptionsPopover, ContextMenuTrigger, FileHeader, Menu, ContextMenu — mounted per-file in the diff view. 12,000-16,000 renders each. ~1,600ms aggregate self-time. + +**Fix:** Follows from fixing Problem 1. These re-render because their parent (AllFilesDiffView) re-renders because App.tsx re-renders. Breaking the cascade fixes this indirectly. + +## Trigger Pattern Breakdown + +| Trigger | Commits | Total ms | Avg ms | Avg fibers | +|---------|---------|----------|--------|------------| +| ReviewApp | 17 | 2,374ms | 140ms | 18,975 | +| ToolbarHost2 | 8 | 1,299ms | 162ms | 13 | +| ReviewApp + ToolbarHost2 | 4 | 829ms | 207ms | 18,986 | +| DockviewReactJsBridge + ReviewApp | 3 | 767ms | 256ms | 18,987 | +| LazyFileDiff + ReviewApp | 4 | 627ms | 157ms | 18,975 | +| FileHeader | 3 | 530ms | 177ms | 8,537 | +| LazyFileDiff | 5 | 191ms | 38ms | 862 | + +## Top Components by Aggregate Self-Time + +| Component | Instances | Self ms | Renders | +|-----------|-----------|---------|---------| +| FileTreeNodeItem | 531 | 1,202ms | 15,399 | +| DiffOptionsPopover | 426 | 381ms | 14,058 | +| ContextMenuTrigger | 426 | 268ms | 12,354 | +| FileHeader | 426 | 255ms | 16,614 | +| Menu | 427 | 195ms | 12,383 | +| ContextMenu | 426 | 175ms | 12,354 | +| MenuPortal | 427 | 159ms | 12,383 | +| ReviewApp | 1 | 154ms | 29 | +| MenuProvider | 854 | 133ms | 24,766 | +| FileTree | 1 | 122ms | 57 | +| WorktreePicker | 1 | 111ms | 29 | + +## Most Frequently Re-Rendered Components + +| Component | Instances | Renders | +|-----------|-----------|---------| +| Context.Provider | 3,421 | 104,321 | +| PopperAnchor | 855 | 26,499 | +| Presence | 855 | 26,499 | +| PopperProvider | 855 | 26,499 | +| MenuProvider | 854 | 24,766 | +| FileHeader | 426 | 16,614 | +| FileTreeNodeItem | 531 | 15,399 | +| LazyFileDiff | 426 | 14,909 | + +## Priority Order + +1. **Callback stabilization** — directly addresses 55% of render time (ReviewApp cascades) +2. **FileTreeNodeItem memo** — addresses 14% of render time +3. **Per-file Radix churn** — 18%, but resolves automatically when #1 stops the cascade + +## Raw Data + +Profiler JSON: `profiling-data.05-20-2026.16-23-25.json` (91MB, not committed) diff --git a/goals/performance/baseline-plan-review-2026-05-20.md b/goals/performance/baseline-plan-review-2026-05-20.md new file mode 100644 index 000000000..1157e0137 --- /dev/null +++ b/goals/performance/baseline-plan-review-2026-05-20.md @@ -0,0 +1,93 @@ +# Plan Review Profiler Baseline — 2026-05-20 + +Captured via React DevTools Profiler on the plan review surface. + +## Summary + +- **24 commits**, **325ms** total render time +- Much lighter than code review (8,815ms) — fewer components, no dockview panels + +## Expensive Commits + +| Commit | Duration | Notes | +|--------|----------|-------| +| 20 | 46.9ms | | +| 22 | 45.8ms | | +| 23 | 43.6ms | | +| 11 | 42.5ms | | +| 16 | 37.5ms | | +| 17 | 35.5ms | | +| 7 | 27.4ms | | +| 10 | 23.3ms | | + +## Component Render Tree (hotspot) + +``` +App +└─ OverlayScrollArea + └─ Viewer key="plan" + ├─ DocBadges + ├─ AttachmentsButton + ├─ BlockRenderer x ~80 + ├─ TableBlock x 5 + ├─ InlineMarkdown x many + └─ CodeFileLink x many +``` + +## Top Components by Inclusive Time + +| Component | Inclusive ms | Renders | +|-----------|-------------|---------| +| Viewer / Anonymous key=plan | 226.3 | 6 | +| BlockRenderer (aggregate) | 184.2 | 778 | +| App | 131.9 | 3 | +| TableBlock (aggregate) | 103.1 | 45 | +| InlineMarkdown (aggregate) | 103.1 | 888 | +| AppSidebar | 37.0 | 2 | +| CodeFileLink (aggregate) | 24.7 | 164 | + +## Top Components by Self-Time + +| Component | Self ms | +|-----------|---------| +| Viewer / Anonymous key=plan | 39.2 | +| App | 18.5 | +| TableBlock block-15 | 15.4 | +| TableBlock block-7 | 12.5 | +| AppSidebar | 8.6 | +| AppSettingsDialog | 8.2 | + +## Root Cause + +The entire document viewer re-renders repeatedly — every markdown block, table, inline markdown cell, and code-file link. Re-renders cascade from parent state changes in App / Viewer, not from individual component state. + +## Likely Causes + +1. **App.tsx passes unstable props/callbacks to Viewer** — inline arrow functions (`onPlanDiffToggle={() => ...}`), inline objects (`linkedDocInfo={{ ... }}`), arrays that create new references on every render +2. **Viewer re-render cascades to all BlockRenderers** — 778 renders of BlockRenderer, 888 renders of InlineMarkdown, no memo boundaries +3. **Tables are a local hotspot** — TableBlock block-15: 43.4ms, block-7: 24.2ms, block-47: 14.1ms +4. **CodeFileLink renders are noisy** — 164 renders total + +## Update Sources + +- Viewer / Anonymous key=plan +- App +- TableToolbar +- AnnotationToolbar +- CodeFileLink +- AppSidebar / LayoutContent / Layout + +## Key Files + +- `packages/plannotator-plan-review/App.tsx` +- `packages/ui/components/Viewer.tsx` +- `packages/ui/components/TableBlock.tsx` +- `packages/ui/components/InlineMarkdown.tsx` +- `packages/ui/components/CodeFileLink.tsx` + +## Recommended Fixes + +- `React.memo` on BlockRenderer, TableBlock, InlineMarkdown, CodeFileLink +- Stabilize Viewer props in App.tsx with useMemo / useCallback +- Avoid inline object/function props to Viewer +- Split volatile annotation/selection state from static markdown rendering diff --git a/goals/performance/findings.md b/goals/performance/findings.md new file mode 100644 index 000000000..879e1c279 --- /dev/null +++ b/goals/performance/findings.md @@ -0,0 +1,204 @@ +# Performance Findings — Multi-Session Frontend + +Comprehensive sweep of performance killers in the multi-session keep-alive architecture. The app feels generally slow with 3+ sessions open — not during specific actions, but during normal use: scrolling, clicking files, hovering, navigating. + +> [!NOTE] +> **Status — re-inventoried 2026-05-28 against HEAD `6e65aa37` (post main-merge): 8 fixed · 2 partial · 29 open.** +> The cross-session-interference tier (hidden sessions corrupting the active one) is largely resolved; the heavy *structural* work remains and is the biggest lever left on perceived speed. +> +> - **Fixed (8):** #1 SessionSurface memoized · #6 StickyHeaderLane scoped to container · #7 CSS-property writes scoped to session root · #10 PlanCleanDiffView scoped · #11 TableOfContents scoped · #12 `document.title` visibility-guarded · #24 + #30 configStore → Zustand selectors (PR #808). +> - **Partial (2):** #8 ThemeProvider still writes global `document` theme classes · #18 split-drag has a ref cache but still `setState`s per pointermove. +> - **Open (29):** #2 #3 #4 #5 #9 #13 #14 #15 #16 #17 #19 #20 #21 #22 #23 #25 #26 #27 #28 #29 #31 #32 #33 #34 #35 #36 #37 #38 #39. +> Headliners: **#31** zero code-splitting (18MB single-file), **#5** sessions never evicted, **#3/#4/#39** App.tsx monolith + `ReviewSidebar` memo still commented out + no memo boundaries, **#16** Pierre diffs never unmount, **#32** mermaid/viz.js eagerly imported, **#33/#34** inlined fonts + all-themes CSS, **#26–#29** unpurged Maps / uncancelled rAF / per-hidden-session pollers, **#36** two WebSocket connections. +> +> Per-finding detail below is the original sweep (kept as the reference). The global-keyboard-registry item (`backlog/global-keyboard-registry.md`) folds into this work. + +## Tier 1 — Causes general sluggishness during normal use + +### 1. SessionSurface is not memoized + +`apps/frontend/src/components/sessions/SessionSurface.tsx` is a plain function component with no `React.memo` wrapper. It's rendered inside `Layout.tsx`'s `Object.values(visitedSessions).map(...)`. + +Every time Layout re-renders — sidebar toggle, session switch, dialog open/close, `addProjectOpen` changing — React walks the ENTIRE component tree of EVERY mounted session. Layout re-renders frequently because it subscribes to `activeSessionId`, `visitedSessions`, `addProjectOpen`, and `useSidebar()` (context). + +With 3 sessions mounted: every sidebar toggle triggers 3 full code-review tree reconciliations. + +### 2. DOM weight with visibility:hidden + +Each code review session produces 20,000–40,000 DOM nodes (header, file tree, dockview, Pierre diffs, sidebar, modals). Pierre diffs mount lazily but never unmount — `LazyFileDiff` sets `mounted = true` but never resets to `false`. Once a user scrolls through 50 files, all 50 diff trees stay in the DOM permanently. + +With 3 sessions: 60,000–120,000 nodes in the layout tree. + +`visibility: hidden` hides pixels but the browser still computes layout for every hidden node on every style recalculation. The global `* { transition-property: ... }` rule in `theme.css` forces CSS selector matching against all 100k+ nodes on every style invalidation, even though `transition-duration: 0s` is applied to hidden subtrees. + +`content-visibility: hidden` would tell the browser to skip layout AND style recalculation entirely on hidden subtrees. Currently not used. + +### 3. 57 useState in App.tsx — the monolith re-renders on every interaction + +`packages/plannotator-code-review/App.tsx` has 57 `useState` calls. ANY state change re-renders the entire 2500-line component. This includes: +- `allFilesVisibleFile` — set on file-boundary crossings while scrolling diffs (line 1425) +- `splitRatio` — set on every pointer pixel during split handle drag +- `isAllFilesActive` / `isDiffPanelActive` — set on every dockview panel focus change + +Every one of these state changes cascades to the unmemoized `ReviewSidebar` and all other children. + +### 4. ReviewSidebar has React.memo explicitly commented out + +`packages/plannotator-code-review/components/ReviewSidebar.tsx` line 108 — `/* React.memo */` is commented out. ReviewSidebar is a child of the 2500-line App.tsx. Every one of App's 57 state changes triggers a ReviewSidebar reconciliation. + +### 5. Sessions are never evicted from visitedSessions + +`apps/frontend/src/stores/app-store.ts` — `removeSession` is defined but never called anywhere in the codebase. Sessions only accumulate. A user who opens 10 sessions over a working day has 10 full React trees mounted with ~200,000+ DOM nodes. + +## Tier 2 — Cross-session interference (hidden sessions degrading active session) + +### 6. StickyHeaderLane uses unscoped document.querySelector + +`packages/ui/components/StickyHeaderLane.tsx` line 148 — queries `document.querySelector('[data-sticky-actions]')` with no container scoping. With 3 sessions, each StickyHeaderLane finds the FIRST matching element in the document — which belongs to a DIFFERENT session. It then attaches a ResizeObserver to that foreign element. Hidden sessions observe the active session's DOM nodes, firing N-1 extra ResizeObserver callbacks on every layout change. + +### 7. CSS custom property stomping from hidden sessions + +`packages/plannotator-code-review/App.tsx` line 170 — sets `document.documentElement.style.setProperty('--diffs-font-family', ...)` etc. when config changes. All sessions write to the same `:root` element. Each `setProperty` invalidates every CSS rule referencing those variables — full global style recalculation across the entire 60k-120k node document. + +### 8. ThemeProvider race on document.documentElement.classList + +`packages/ui/components/ThemeProvider.tsx` lines 44-57 — every session mounts its own ThemeProvider that strips and re-adds `theme-*` classes on `document.documentElement`. Three ThemeProviders racing to control the document class list. Each write triggers a full-document style recalculation. + +### 9. Hidden session paste handlers eat clipboard events + +`packages/editor/App.tsx` line 930 — unguarded `document.addEventListener('paste')` in every session. Hidden sessions call `e.preventDefault()` on image pastes, which suppresses the paste from reaching the active session. User's paste gets silently eaten. + +### 10. PlanCleanDiffView uses unscoped querySelector + scrollIntoView + +`packages/ui/components/plan-diff/PlanCleanDiffView.tsx` lines 103-107 — `document.querySelector('[data-diff-block-index]')` finds elements from ANY session. A hidden session's annotation event can add highlight classes and call `scrollIntoView` on the ACTIVE session's DOM — causing phantom scroll jumps. + +### 11. TableOfContents uses unscoped querySelector + +`packages/ui/components/TableOfContents.tsx` line 175 — `document.querySelector('[data-block-id="..."]')` finds the first matching element globally. Hidden session TOC clicks scroll the active session's content. + +### 12. Hidden session document.title mutation + +`packages/plannotator-code-review/App.tsx` line 225 — `useEffect` sets `document.title` on `repoInfo` change with no visibility guard. Hidden sessions overwrite the visible session's title. + +### 13. useAnnotationHighlighter capture-phase mouseup in every session + +`packages/ui/hooks/useAnnotationHighlighter.ts` line 99 — `document.addEventListener('mouseup', track, true)` with capture phase. All sessions register. Every click fires N capture-phase callbacks. Low per-call cost but adds up. + +## Tier 3 — Component-level inefficiencies (within a single session) + +### 14. ScrollFade double setState on every scroll tick + +`packages/plannotator-code-review/components/ScrollFade.tsx` — calls `setShowTop` and `setShowBottom` on its scroll handler with no equality guard. Every scroll event triggers 2 state updates, re-rendering the file tree panel ~60 times per second while scrolling. + +### 15. FileHeader ResizeObserver per file + +`packages/plannotator-code-review/components/FileHeader.tsx` line 71 — each file header creates its own `ResizeObserver` that calls `setHeaderWidth`. During window resize, N observers fire N `setState` calls simultaneously. No quantization. + +### 16. Pierre diffs never unmount + +`packages/plannotator-code-review/components/LazyFileDiff.tsx` — `mounted` state is only ever set to `true`, never back to `false`. Once a file diff is mounted by IntersectionObserver, it stays in the DOM permanently. Node count is monotonically increasing per session throughout its lifetime. + +### 17. allFilesVisibleFile scroll handler re-renders entire App + +`packages/plannotator-code-review/App.tsx` line 1425 — `setAllFilesVisibleFile` called from scroll handler on file-boundary crossings. Each call re-renders the entire 2500-line App component. + +### 18. splitRatio setState on every pointer move + +`packages/plannotator-code-review/components/DiffViewer.tsx` — `setSplitRatio` fires on every `pointermove` while dragging the split handle. DiffViewer is not wrapped in `React.memo`. + +### 19. AllFilesDiffView: getBoundingClientRect loop on every scroll tick + +`packages/plannotator-code-review/components/AllFilesDiffView.tsx` lines 203-226 — the scroll handler loops through ALL expanded files calling `header.getBoundingClientRect()` on each one, synchronously, on every scroll event. With 50 files expanded, that's 50 forced layout reads per scroll tick. Each `getBoundingClientRect()` forces the browser to flush pending layout. This is layout thrashing — reading layout, potentially writing, reading again — at 60fps scroll rate. + +### 20. reviewStateValue context invalidates on every line click + +`packages/plannotator-code-review/App.tsx` line 1371 — `pendingSelection` is in the `reviewStateValue` useMemo dependency array. `pendingSelection` changes on every diff line click. Since `reviewStateValue` is the `ReviewStateContext.Provider` value, every line click invalidates the context and re-renders ALL dock panels (all-files, code-nav, PR comments, agents) — even panels that don't use `pendingSelection`. + +### 21. getComputedStyle called 4-5 times per keypress across sessions + +`packages/plannotator-code-review/App.tsx` lines 645, 712, 1085, 1688, 1721 — `isVisible()` calls `getComputedStyle(rootRef.current).visibility` as a guard. Each `getComputedStyle()` forces synchronous style recalculation. With 3 sessions × 4 handlers = 12 forced style recalcs per keystroke. Especially bad when typing in annotation inputs. + +### 22. useActiveSection quad-threshold IntersectionObserver + +`packages/ui/hooks/useActiveSection.ts` — configured with `threshold: [0, 0.1, 0.5, 1.0]`. Each heading fires up to 4 callbacks per scroll crossing, each calling `setActiveId` with no equality guard. + +### 23. ToolbarHost global mousemove + +`packages/plannotator-code-review/components/ToolbarHost.tsx` line 92 — `window.addEventListener('mousemove', handleMouseMove)` with no visibility guard. Every mouse movement fires a callback in every mounted session. Handler only writes to a ref (no re-render), but N function calls per mouse move. + +## Tier 4 — Fires intermittently (settings/theme changes only) + +### 24. configStore broadcasts to all subscribers + +`packages/ui/config/configStore.ts` — `notify()` calls every listener on ANY setting change. 14 `useConfigValue` calls per session × N sessions. Only fires when user changes a setting — not during normal use. + +### 25. ThemeProvider context re-renders all useTheme consumers + +Only fires on theme change. Per session: `usePierreTheme`, `DiffHunkPreview`, `ReviewHeaderMenu` — 3 components × N sessions. + +## Tier 5 — Memory leaks and unbounded growth + +### 26. Module-level draft Maps never purged + +`packages/plannotator-code-review/hooks/useAnnotationToolbar.ts` lines 47-48 — `draftStore` and `restoreDraftKeyByFilePath` are module-level `Map` singletons. Keyed by `"filePath:side:start-end"`. Entries are only removed on explicit submit/cancel — never on file abandon or session hide. With large PRs and repeated sessions, both Maps grow without bound across the page lifetime. + +### 27. usePierreTheme fires uncancelled requestAnimationFrame + +`packages/plannotator-code-review/hooks/usePierreTheme.ts` lines 177-283 — `useEffect` schedules `requestAnimationFrame` with no cleanup return. The rAF ID is not stored, so it cannot be cancelled on unmount or dep change. Each dep change (theme toggle, font change) fires a new rAF without cancelling the previous. With N sessions, N rAF callbacks are in-flight simultaneously on every theme change. + +### 28. WebSocket subscriptions and pollers accumulate per hidden session + +`packages/ui/hooks/useExternalAnnotations.ts` and `packages/ui/hooks/useAgentJobs.ts` — each delegates to `useDaemonSessionTransport`, which subscribes to the WebSocket hub and starts a 2-second polling fallback when disconnected. Cleanup only fires on unmount. Since hidden sessions never unmount, N sessions = N active subscriptions + up to N concurrent `setInterval` timers if WebSocket drops. + +### 29. useEditorAnnotations 500ms poller per hidden session (VS Code only) + +`packages/ui/hooks/useEditorAnnotations.ts` line 45 — when `IS_VSCODE` is true, every mounted session starts a `setInterval(..., 500)` fetching `/api/editor-annotations`. Hidden sessions keep polling. N sessions = N separate 500ms pollers. + +### 30. configStore.listeners grows with hidden sessions + +`packages/ui/config/configStore.ts` — `useSyncExternalStore` subscribers are only removed on unmount. Hidden sessions never unmount. With ~15 `useConfigValue` calls per session × N sessions, the listener Set grows to O(15N) and never shrinks during the page lifetime. + +## Tier 6 — Build and architecture (affects every user, every load) + +### 31. Zero code splitting — 18.4MB single-file bundle + +`apps/frontend/vite.config.ts` — `vite-plugin-singlefile` + `inlineDynamicImports: true` forces the entire app into one file. Both full App components (~4800 lines combined), `@pierre/diffs`, `highlight.js`, `motion`, `dockview-react`, mermaid, viz.js, all Radix primitives, all CSS — everything loads on first page load. No lazy loading of any kind. The browser parses and JIT-compiles the entire bundle before anything renders. + +### 32. Mermaid and viz.js statically imported at module level + +`packages/ui/components/MermaidBlock.tsx` line 6 — `mermaid.initialize({...})` runs at module import time. `packages/ui/components/GraphvizBlock.tsx` line 3 — `@viz-js/viz` (1.4MB, contains WASM Graphviz) is statically imported. Both load for every user even if they never see a diagram. These could be `React.lazy()` + dynamic `import()`. + +### 33. 361KB of base64-inlined fonts (all unicode ranges) + +`apps/frontend/src/styles.css` — `@fontsource-variable/inter` loads 7 unicode-range `@font-face` rules (Cyrillic, Greek, Vietnamese, Latin-ext, Latin, etc.). With `viteSingleFile`, all 10 woff2 files are base64-inlined. 361KB of font data that the browser can't skip. Using Latin-only would eliminate ~300KB. + +### 34. 198KB of theme CSS loaded for all 50+ themes + +All theme definitions load at startup even though only one is active. No per-theme splitting. + +### 35. Persistent backdrop-blur on always-visible panels + +`AnnotationPanel`, `AnnotationSidebar`, `TableOfContents`, `StickyHeaderLane` all have `backdrop-blur-sm`. This triggers GPU compositing on the blur filter, which is expensive whenever anything behind those panels changes (scrolling, animations). These are not overlays — they're permanent UI panels. + +### 36. Two separate WebSocket connections to /daemon/ws + +`UiDaemonHubClient` (packages/ui/utils/daemonHub.ts) and `DaemonHubClient` (apps/frontend/src/daemon/events/hub-client.ts) are completely independent clients connecting to the same endpoint. Two TCP connections with separate reconnect timers. Could be one multiplexed connection. + +### 37. 29+ OverlayScrollbars instances with ResizeObserver each + +Used in FileTree, DiffViewer, ReviewSidebar, AITab, PRCommentsTab, LiveLogViewer, TourDialog, Settings, AnnotationPanel, Viewer, TableOfContents, and more. Each instance has its own ResizeObserver. With keep-alive sessions, all instances across all sessions stay alive. + +### 38. getComputedStyle in useState initializer blocks first render + +`packages/plannotator-code-review/hooks/usePierreTheme.ts` line 160 — `getComputedStyle(document.documentElement)` in a `useState` lazy initializer forces synchronous style recalculation during the first render of every code review session mount. + +### 39. Monolithic App components (~2500 lines) with no memo boundaries + +Both `plannotator-code-review/App.tsx` and `plannotator-plan-review/App.tsx` are single giant components. All state, hooks, and render logic in one function. Any state update — even minor ones like `setCopyFeedback` — triggers reconciliation over the entire tree. No `React.memo` boundaries to stop propagation. Dockview, file tree, diff viewer, sidebar, AI chat all re-check props on every update. + +## What's NOT Causing It + +- **Polling/transport hooks** — WebSocket is connected, no timers running when idle +- **Keyboard handlers** — already guarded with `isVisible()`, microsecond no-ops per keystroke +- **requestAnimationFrame loops** — none exist, all one-shot +- **Agent job processing** — only fires when jobs are actually running diff --git a/goals/performance/long-running-session-costs.md b/goals/performance/long-running-session-costs.md new file mode 100644 index 000000000..37d850882 --- /dev/null +++ b/goals/performance/long-running-session-costs.md @@ -0,0 +1,114 @@ +# The Cost of Long-Running Sessions (UI rendering) + +> Investigation, 2026-05-29. "Sessions never die" (they suspend/idle and stay alive +> until cancel or daemon restart) — this documents what that costs in the frontend, +> what the current optimizations cover, and where it breaks. Reconciles with +> `./findings.md`. Read-only audit; cites file:line. + +## The core model (and its assumption) + +Every session you **visit** is added to `appStore.visitedSessions` and rendered as a +**permanently-mounted** panel in `Layout.tsx:67-83` — switching sessions only toggles +`visibility`/`contentVisibility`, it never unmounts. `appStore.removeSession()` exists +but is **never called anywhere** (`s.$sessionId.tsx:22-32` only ever *adds*). There is +**no eviction, LRU, or cap.** + +So the frontend was built assuming sessions are **few and transient**. The single-server +"sessions never die" model violates that: open N sessions over a daemon's lifetime → N +heavy surfaces (code-review diff viewers, plan editors) stay mounted simultaneously, +forever, until a page reload. + +## What holds up (and to what scale) + +These keep the **active** session and **switching** cheap: + +- **`SessionSurface` is `React.memo`'d** (`SessionSurface.tsx:21`) + Layout uses + selector-based Zustand — switching active session doesn't re-render the others. +- **`contentVisibility: hidden` + `containIntrinsicSize`** on inactive panels + (`Layout.tsx:75`) — the browser skips *paint/layout* for hidden sessions (big win; + only the active session pays render cost). +- **Code-review Zustand store** (annotations/files/diff-options slices) + selector + subscriptions + `FileTreeNodeItem` memo — the active code-review surface is the + optimized one. +- **Daemon-shell stores** (app/project/event/git-dashboard) are selector-based, so + unrelated updates don't cascade. + +**Verdict: fine at 1–2 sessions, OK at 3–5.** Beyond that the structural gaps dominate. + +## What fails as sessions accumulate + +**1. Memory / DOM grows unbounded (no eviction).** +Each mounted surface is ~5–20 MB and tens of thousands of DOM nodes; nothing tears them +down. Worse, within a code-review session, Pierre diffs **never unmount once viewed** +(`LazyFileDiff` sets `mounted=true`, never false; findings #16) and the file tree isn't +virtualized — so a 50-file review keeps ~50 diff trees in the DOM, per session. N +sessions stack: ~5–10 MB added per session opened, never reclaimed. + +**2. Every hidden session keeps doing work (pollers/subscriptions/listeners).** +`contentVisibility` stops *painting* but not *JS*. Each mounted session keeps running, +even when hidden: +- 3–4 daemon WS subscriptions (external-annotations, agent-jobs, session-revision) each + with a **2s HTTP polling fallback** (`useDaemonSessionTransport.ts`, `useExternalAnnotations.ts:21`, `useAgentJobs.ts:20`); +- a 500ms draft-autosave debounce when there are edits; +- global `keydown` listeners (`code-review App.tsx:734`, `plan App.tsx:842`) — every + keystroke fires on all K sessions; +- MutationObserver / ResizeObserver / IntersectionObserver per session; +- `configStore.listeners` grows ~O(15·N) and never shrinks. +- The `useSessionVisible()` signal exists but is **only used for `document.title`** — it + does **not** gate any of the above. So K hidden sessions = K× pollers + listeners + running. Idle CPU creeps up (~15–25% at 5–10 sessions). + +**3. List churn: every session event re-renders everything, unvirtualized.** +`event-store.ts:67-71` replaces the `sessions` array on **every** `session-updated` +(status flips, `updatedAt` bumps). That re-runs the sidebar's `buildSessionTree` +(`AppSidebar.tsx:185`) and re-renders the whole tree + the conjoined sessions/history +lists — **none are virtualized**. With many live sessions emitting events, this is +repeated full-array work per event. + +**4. The App monoliths still reconcile on hot paths.** +Code-review `App.tsx` (~2600 lines) keeps hot-path state in `useState` (split-drag, +all-files scroll) that fires ~60×/sec during drag/scroll → full App reconcile; +`ReviewSidebar`'s memo is **commented out**. `contentVisibility` skips *rendering* the +hidden sessions but not React's *reconciliation* of the active monolith. Plan-review is +even less optimized (largely `useState`). + +**5. 19 MB single-file bundle, zero code-splitting.** +`vite.config.ts` (`viteSingleFile` + `inlineDynamicImports` + `cssCodeSplit:false`) → +one ~19 MB artifact: both apps, highlight.js, @pierre/diffs, dockview, mermaid, viz.js, +50+ theme CSS, all fonts base64-inlined. No `React.lazy`. Parsed/compiled up front, +resident per tab regardless of how many sessions or which mode is active. + +## Survivability estimate + +| Sessions | Behavior | +|---|---| +| 1–2 | Fully fine. | +| 3–5 | OK — `contentVisibility` + memo + selectors carry it; active session ~60fps. | +| 5–10 | **Degraded** — ~100–150 MB, idle CPU ~15–25% (pollers), occasional scroll/drag jank. | +| 10+ | **Fails** — 200 MB+, GC pauses (100–200ms), <20fps interaction; user closes tabs or it crashes. | + +## Reconciliation with findings.md + +This isn't new debt — it's the **same** open items in `findings.md`, **amplified** by the +never-die model (they go from "one session degrades over time" → "N sessions stack"): +- Tier 1: SessionSurface memo (fixed) + contentVisibility (mitigates DOM weight) + the + App.tsx monolith (#3, **open**). +- Tier 3: scroll/resize re-renders, per-file observers (**open**). +- Tier 5: unbounded growth — Maps never purged, pollers/listeners accumulate (#26–#30, + **open**) — this is the one the session model makes acute. +- Tier 6: no code-splitting, eager mermaid/viz.js (#31–#37, **open**). + +## What would make it survivable (priority order) + +1. **Evict / cap mounted sessions** — keep only the active + a small recent set mounted; + unmount the rest (re-mount on revisit from bootstrap). The single highest-leverage fix + for the never-die model; directly bounds 1, 2, and the DOM side of 4. +2. **Gate per-session work on visibility** — thread `useSessionVisible()` into the + subscription/poller hooks so hidden sessions pause (kills the K× pollers/listeners). +3. **Virtualize** the file tree, the diff list, and the session/history lists. +4. **De-monolith the App hot paths** — move split-drag/scroll state into the store with + selectors; re-enable the `ReviewSidebar` memo. +5. **Code-split** — lazy-load code-review vs plan-review; defer mermaid/viz.js. + +Note: the daemon/AI layer already evicts idle sessions server-side +(`packages/ai/session-manager.ts`); the **frontend has no equivalent** — that's the gap. diff --git a/goals/session-lifecycle/facts.md b/goals/session-lifecycle/facts.md new file mode 100644 index 000000000..6a605883b --- /dev/null +++ b/goals/session-lifecycle/facts.md @@ -0,0 +1,63 @@ +# Session Lifecycle — Facts + +## Smart Session Opening + +- When a CLI command creates a session, the daemon (not the CLI) decides how to present it to the user. +- If no frontend WebSocket client is connected, the daemon calls `openBrowser()` with the session URL. A new tab opens. +- If a frontend is connected and the tab is visible, the daemon sends a WebSocket notify event. The frontend shows an auto-dismissing toast (5-10 seconds) with the session mode, project name, and a button to go to it. The frontend never auto-navigates — the user always chooses when to switch. +- If a frontend is connected but the tab is not visible (user is in another app), the daemon calls `openBrowser()`. This forces the browser to the foreground with a new tab. +- The frontend reports tab visibility to the daemon over WebSocket using `document.visibilityState`. The daemon tracks this per connection. +- The frontend reports which session is currently active (or null for landing page) over WebSocket when navigation changes. The daemon tracks this per connection. +- If multiple frontend tabs are connected, navigate/notify events are broadcast to all of them. +- The CLI no longer calls `openBrowser()` itself. The `POST /daemon/sessions` response includes a field indicating what the daemon did (`opened`, `navigated`, `notified`). + +## First-Open Experience + +- Direct session links (`/s/:id`) render with the sidebar collapsed by default. +- The session surface fills the screen. The sidebar is discoverable but not in the way. +- The landing page (`/`) shows the sidebar open as normal. + +## Notification Behavior + +- Toasts only appear when the frontend tab is focused and the user is in an active session. +- Toasts auto-dismiss after 5-10 seconds. +- Each toast shows the session mode (plan, review, annotate, etc.), the project name, and a clickable action to navigate to it. +- The sidebar always reflects new sessions immediately via WebSocket, regardless of tab visibility or toast state. + +## Session Completion + +- When a user approves or denies a session, the `CompletionBanner` appears inline (already implemented). +- Action buttons in the header hide after submission (already implemented). +- The session content remains visible and scrollable after a decision. No auto-navigate, no redirect. +- The session stays in the sidebar with a visual status indicator (approved/denied badge). It does NOT disappear. +- Completed sessions in the sidebar are visually distinct from active sessions. + +## Session Persistence (Disk-Backed) + +- When a session completes, the daemon preserves the session content to disk before disposing the handler. + - Note: plan history already saves to `~/.plannotator/history/`. This system should be leveraged or modified if needed. +- Completed sessions serve read-only content from disk. The plan/diff/annotation data is available even after the handler is disposed. +- Completed sessions survive page refresh. Navigating to `/s/:id` for a completed session loads the read-only content from disk. +- Completed sessions survive daemon restart. The daemon reads session records and content from disk on startup. + +## Mode Parity + +- All session modes (plan, review, annotate, archive, goal-setup) follow the same opening, notification, sidebar, and completion flow. +- No mode-specific UX for how sessions appear, are notified, or complete. The only difference is the surface content inside the session. + +## Legacy Tab Mode + +- A config value in `~/.plannotator/config.json` enables legacy tab mode. +- When legacy mode is enabled, the daemon always calls `openBrowser()` for each new session, regardless of frontend connection state. No navigate/notify events. +- The frontend still renders (it's always the new UI), but each session opens in its own tab. +- The `CompletionOverlay` with auto-close fires in legacy mode (not the inline `CompletionBanner`). +- The existing `plannotator-auto-close` cookie controls the close timing (off, immediate, 3s, 5s). +- Legacy mode is opt-in. The default experience is the smart single-app model. + +## Out of Scope + +- Session reactivation (agent resubmits plan → session comes back to life). Follow-up goal. +- Historical session browsing (sessions from weeks ago). Follow-up goal — disk persistence here is the foundation. +- Sidebar hierarchy redesign (project-based vs mode-based grouping). Separate design exploration. +- In-app notification permissions or OS-level push notifications. +- First-run tutorial or "what's new" overlay. diff --git a/goals/session-lifecycle/goal.md b/goals/session-lifecycle/goal.md new file mode 100644 index 000000000..6776aaa6c --- /dev/null +++ b/goals/session-lifecycle/goal.md @@ -0,0 +1,21 @@ +# Session Lifecycle: Smart Opening, Persistence, and Legacy Mode + +Make the daemon the decision-maker for how sessions are presented to users. Instead of the CLI always opening a new browser tab, the daemon checks whether a frontend is connected and visible, and chooses between opening a browser or sending an in-app notification. Session content persists to disk so completed sessions survive refresh and restart. A legacy config toggle preserves tab-per-session + auto-close for users who prefer it. + +## Shared Understanding + +See `facts.md` for the complete fact sheet (approved). + +## Execution Plan + +See `plan.md` for the implementation plan. Also available in the approved Plannotator plan at `~/.claude/plans/adaptive-questing-bee.md`. + +## Done Condition + +All verification items in the plan pass: +- Smart opening works across all four states (no frontend, visible idle, visible active, backgrounded) +- Completed sessions stay in sidebar with status badges +- Completed sessions survive page refresh and daemon restart via disk snapshots +- Legacy tab mode opens new tabs and fires auto-close overlay +- All five session modes follow the same flow +- Typecheck and tests pass diff --git a/goals/session-lifecycle/manual-test.md b/goals/session-lifecycle/manual-test.md new file mode 100644 index 000000000..1fea1acec --- /dev/null +++ b/goals/session-lifecycle/manual-test.md @@ -0,0 +1,144 @@ +# Session Lifecycle — Manual Testing + +## Setup + +```bash +git checkout feat/session-lifecycle +bun run --cwd apps/frontend build +bun run dev:frontend +``` + +Note the daemon port from the output. The frontend runs on its own Vite port (probably 3003). + +--- + +## 1. No frontend open — browser should open + +Close all browser tabs. Run: + +```bash +printf '{"hook_event_name":"PermissionRequest","tool_name":"ExitPlanMode","tool_input":{"plan":"# Test Plan\n\n- [ ] Step one"},"permission_mode":"default"}' | bun apps/hook/server/index.ts +``` + +**Expect:** A new browser tab opens with the plan. Sidebar is collapsed. Plan content visible. + +--- + +## 2. Frontend visible — toast instead of new tab + +Keep the tab from test 1 open. In another terminal, run: + +```bash +PORT=$(jq -r .port ~/.plannotator/daemon.json) +curl -s -X POST "http://localhost:${PORT}/daemon/sessions" \ + -H "Content-Type: application/json" \ + -d '{"request":{"action":"plan","origin":"claude-code","cwd":"'$(pwd)'","plan":"# Second Plan\n\nThis should toast."}}' +``` + +**Expect:** No new tab. A toast appears in the existing tab (bottom-right) with "Plan Review — [project]" and an "Open" button. Toast dismisses after ~8 seconds. Sidebar shows the new session. + +--- + +## 3. Toast action navigates + +Click the "Open" button on the toast before it dismisses. + +**Expect:** Frontend navigates to the new session. Plan content shows "Second Plan." + +--- + +## 4. Approve a plan — banner, not overlay + +Click "Approve" on the current plan. + +**Expect:** Green banner at top: "Plan Approved — Claude Code will proceed with the implementation." No full-screen overlay. No auto-close. Action buttons disappear. Plan content stays visible and scrollable. Session stays in sidebar with a status badge. + +--- + +## 5. Refresh after approval — content survives + +Hard refresh the page (Cmd+R). + +**Expect:** The plan content reloads. The banner shows again. Content is served from the disk snapshot. + +--- + +## 6. Sidebar persistence + +Open the sidebar (click the trigger). + +**Expect:** Completed sessions show with a visual indicator (check icon, muted text). Active sessions show with a green dot. Completed sessions are clickable and navigate to their content. + +--- + +## 7. Frontend backgrounded — new tab opens + +Switch to a different app (e.g., VS Code or Finder) so the browser tab is not visible. Run: + +```bash +curl -s -X POST "http://localhost:${PORT}/daemon/sessions" \ + -H "Content-Type: application/json" \ + -d '{"request":{"action":"review","origin":"claude-code","cwd":"'$(pwd)'","args":""}}' +``` + +**Expect:** Chrome comes to the foreground with a new tab showing the code review session. + +--- + +## 8. Daemon restart — old sessions appear + +Stop the dev server (Ctrl+C). Restart: + +```bash +bun run dev:frontend +``` + +Open the frontend. Open the sidebar. + +**Expect:** Completed sessions from previous daemon run appear in the sidebar. Clicking one loads its content from disk. + +--- + +## 9. Direct session link — sidebar collapsed + +Copy a session URL (e.g., `http://localhost:PORT/s/sess_abc...`). Open it in a new tab. + +**Expect:** Session loads with sidebar collapsed. Session surface fills the screen. + +Navigate to `http://localhost:PORT/` (root). + +**Expect:** Sidebar is open. + +--- + +## 10. Legacy tab mode + +Edit `~/.plannotator/config.json`, add `"legacyTabMode": true`. With the frontend open and visible, run: + +```bash +printf '{"hook_event_name":"PermissionRequest","tool_name":"ExitPlanMode","tool_input":{"plan":"# Legacy Test\n\nShould open new tab."},"permission_mode":"default"}' | bun apps/hook/server/index.ts +``` + +**Expect:** A new browser tab opens (even though frontend is already visible). Approve the plan — full-screen overlay appears with auto-close countdown (not the inline banner). + +Remove `"legacyTabMode": true` from config when done. + +--- + +## 11. All modes work + +Create sessions for each mode and verify they all follow the same flow: + +```bash +# Annotate +curl -s -X POST "http://localhost:${PORT}/daemon/sessions" \ + -H "Content-Type: application/json" \ + -d '{"request":{"action":"annotate","origin":"claude-code","cwd":"'$(pwd)'","markdown":"# Test annotation","filePath":"test.md"}}' + +# Archive +curl -s -X POST "http://localhost:${PORT}/daemon/sessions" \ + -H "Content-Type: application/json" \ + -d '{"request":{"action":"archive","origin":"claude-code","cwd":"'$(pwd)'"}}' +``` + +**Expect:** Each mode toasts (if frontend visible) or opens a tab (if not). Sidebar shows all. Completion behavior is identical. diff --git a/goals/session-lifecycle/plan.md b/goals/session-lifecycle/plan.md new file mode 100644 index 000000000..964b0c719 --- /dev/null +++ b/goals/session-lifecycle/plan.md @@ -0,0 +1,134 @@ +# Session Lifecycle — Plan + +## Approach + +Six workstreams, ordered by dependency. Each builds on the previous. The daemon becomes the orchestrator for session presentation — it decides open vs notify based on frontend state. The CLI becomes a thin sender. Session content persists to disk so completed sessions survive refresh and restart. + +--- + +## Step 1: Frontend visibility and focus reporting + +The daemon needs to know: is a frontend connected, is the tab visible, and what session is the user on. + +**Files:** +- `packages/shared/daemon-protocol.ts` — add client message types: `{ type: "visibility", visible: boolean }` and `{ type: "focus", sessionId: string | null }` +- `packages/server/daemon/event-hub.ts` — handle new message types, track per-connection state +- `apps/frontend/src/daemon/events/event-stream.ts` — send visibility changes via `document.visibilitychange` listener, send focus changes on route navigation + +**Verification:** +- Unit test: event hub tracks visibility per connection +- Manual: daemon logs show visibility/focus changes as you switch tabs and navigate + +--- + +## Step 2: Move browser opening from CLI to daemon + +The daemon decides what to do when a session is created. The CLI stops calling `openBrowser()`. + +**Files:** +- `packages/server/daemon/session-factory.ts` — after `context.store.create()`, call a new `presentSession()` function that checks frontend connections and decides: `openBrowser()`, `notify`, or `openBrowser()` (backgrounded tab) +- `packages/server/daemon/server.ts` — add `browserAction` field to the `POST /daemon/sessions` response +- `packages/shared/daemon-protocol.ts` — add `browserAction: "opened" | "notified"` to `DaemonSessionSummary` +- `apps/hook/server/index.ts` — remove `handleServerReady()`, `handleReviewServerReady()`, `handleAnnotateServerReady()` calls from `runDaemonSessionRequest()` (lines 712-718). Remove the `openBrowser()` call in the sessions command (line 878). +- `packages/server/daemon/event-hub.ts` — add `hasFrontendClient()` and `getFrontendVisibility()` methods + +**Verification:** +- With no frontend open: CLI triggers plan → browser opens +- With frontend visible on landing: CLI triggers plan → toast appears, no new tab +- With frontend visible on active session: CLI triggers plan → toast appears +- With frontend tab backgrounded: CLI triggers plan → new tab opens, Chrome comes to front + +--- + +## Step 3: Session notification toast + +The frontend receives `session-created` events (already wired) and shows a toast when the daemon chose to notify instead of opening a browser. + +**Files:** +- `packages/shared/daemon-protocol.ts` — add a `presentationAction` field to `session-created` events: `"opened" | "notified"` +- `apps/frontend/src/daemon/events/event-store.ts` — on `session-created` with `presentationAction: "notified"`, trigger a toast +- `apps/frontend/src/daemon/events/event-stream.ts` — check `document.hidden` before showing toast. If hidden, queue and show on `visibilitychange` +- Toast component — auto-dismiss 5-10s, shows mode icon + project name + "Open" button that navigates to `/s/:id` + +**Verification:** +- Toast appears when a session is created while frontend is visible and active +- Toast includes session mode, project, and clickable action +- Toast auto-dismisses +- Queued toasts show when tab regains focus + +--- + +## Step 4: Sidebar session persistence + +Completed sessions stay in the sidebar with status badges instead of disappearing. + +**Files:** +- `apps/frontend/src/daemon/events/event-store.ts` — remove the splice on terminal status (line 62-64). Instead, update the session in-place with the new status. +- `apps/frontend/src/components/sidebar/AppSidebar.tsx` — render completed sessions with visual distinction: muted text, status badge (checkmark for approved, x-circle for denied), grouped below active sessions or inline with a visual separator + +**Verification:** +- Approve a plan → session stays in sidebar with approved badge +- Deny a plan → session stays with denied badge +- Active sessions are visually distinct from completed ones +- Clicking a completed session navigates to it and shows the banner + content + +--- + +## Step 5: Session content caching (disk-backed) + +Completed sessions serve read-only content from disk. Survives page refresh and daemon restart. + +**Files:** +- `packages/server/daemon/session-store.ts` — before `releaseRoutingPayloads()` in `disposeRecord()`, snapshot the session content: + - For plan sessions: plan markdown, annotations, version info + - For review sessions: raw patch, git ref, diff metadata + - For annotate sessions: markdown content, file path, source info + - Write snapshot to `~/.plannotator/sessions/.json` +- `packages/server/daemon/server.ts` — when a request hits `/s/:id/api/...` and the session handler is disposed, check for a snapshot on disk. Serve read-only responses from it. +- `packages/server/daemon/session-store.ts` — on daemon startup, scan `~/.plannotator/sessions/` to populate the session list with completed records (so they show in the sidebar) + +**Verification:** +- Approve a plan → refresh the page → plan content still loads +- Restart daemon → navigate to `/s/:id` → plan content loads from disk +- Sidebar shows sessions from previous daemon runs + +--- + +## Step 6: Legacy tab mode + +Config toggle for users who prefer tab-per-session with auto-close. + +**Files:** +- `packages/shared/config.ts` — add `legacyTabMode?: boolean` to `PlannotatorConfig` +- `packages/server/daemon/session-factory.ts` — in `presentSession()`, if `legacyTabMode` is enabled, always call `openBrowser()` regardless of frontend state +- `packages/plannotator-plan-review/App.tsx` and `packages/plannotator-code-review/App.tsx` — read legacy mode from daemon config (served via `/api/plan` or `/api/diff` response). When active, render `CompletionOverlay` instead of `CompletionBanner`, even in embedded mode. + +**Verification:** +- Set `legacyTabMode: true` in `~/.plannotator/config.json` +- CLI triggers plan → new browser tab opens (even if frontend is connected elsewhere) +- Approve → full-screen overlay appears with auto-close countdown +- Tab closes (or shows "close manually" fallback) + +--- + +## Step 7: Sidebar collapsed on direct session links + +Direct `/s/:id` links start with the sidebar collapsed. + +**Files:** +- `apps/frontend/src/app/Layout.tsx` — detect if the initial route is a session route. If so, initialize sidebar as collapsed. + +**Verification:** +- Navigate to `localhost:PORT/s/:id` directly → sidebar is collapsed, session fills the screen +- Navigate to `localhost:PORT/` → sidebar is open as normal +- Opening sidebar manually works, persists for the rest of the session + +--- + +## Risks + +- **Disk I/O on every session completion**: Writing snapshots to disk adds latency. Mitigation: async write, don't block the completion response. +- **Snapshot format stability**: If the frontend evolves, old snapshots may not render correctly. Mitigation: include a version field in the snapshot format. +- **WebSocket race on session creation**: The daemon may try to notify before the frontend has processed a previous navigate. Mitigation: frontend deduplicates by session ID. +- **Review session snapshots are large**: A full git diff can be significant. Mitigation: only cache the metadata needed to re-render, not the full patch. Or accept the size for now and optimize later. +- **Legacy mode + auto-close in embedded app**: `window.close()` in the production frontend would close the entire app, not just a tab. Mitigation: legacy mode must ensure each session is in its own tab (daemon always calls `openBrowser`), so `window.close()` only closes that tab. diff --git a/goals/session-persistence-wireframe.html b/goals/session-persistence-wireframe.html new file mode 100644 index 000000000..8b5763263 --- /dev/null +++ b/goals/session-persistence-wireframe.html @@ -0,0 +1,269 @@ + + + + + +Session Persistence — UI Wireframe + + + + +
+
1. User Reviewing
+
2. Awaiting Revision
+
3. Revision Arrived
+
4. Final Approval
+
5. Sidebar States
+
+ + +
+
+ +
+
+ Implementation Plan + v1 +
+
+
+

Implementation Plan

+

Overview

+

This plan introduces session persistence so denied plans stay alive and can be revised by the agent...

+

Phase 1: Protocol Changes

+

Add "awaiting-resubmission" to the session status enum. Add suspend() and reactivate() methods to the session store...

+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ Implementation Plan + v1 · denied +
+ +
+
+

Implementation Plan

+

Overview

+

This plan introduces session persistence so denied plans stay alive and can be revised by the agent...

+

Phase 1: Protocol Changes

+

Add "awaiting-resubmission" to the session status enum. Add suspend() and reactivate() methods to the session store...

+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ Implementation Plan + v2 + +12 / -3 +
+ +
+
+

Implementation Plan

+

Overview

+

This plan introduces session persistence so denied plans stay alive and can be revised by the agent...

+

Phase 1: Protocol Changes

+
Add "awaiting-resubmission" to the session status enum.
+
Add "awaiting-resubmission" to DaemonSessionStatus. Bump protocol version to 2.
+
Add suspend() and reactivate() methods to the session store.
+

The session store gets two new methods that manage the non-terminal suspension lifecycle...

+

Phase 2: Decision Cycles

+
Replace the one-shot decision promise with a cycle-based model where each deny starts a new cycle.
+
Update the CLI to handle "awaiting-resubmission" as a valid non-error status (exit 0, not 1).
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ Implementation Plan + v2 · approved +
+
+
+

Implementation Plan

+
+
+
+
+
+

Plan Approved

+

Your approval has been sent to the agent.

+

You can close this tab and return to your editor.

+
+
+
+
+
+ + +
+
+ +
+
+ + + + + diff --git a/goals/session-persistence/decisions.md b/goals/session-persistence/decisions.md new file mode 100644 index 000000000..848a7dee9 --- /dev/null +++ b/goals/session-persistence/decisions.md @@ -0,0 +1,160 @@ +# Session Persistence — Design Decisions + +Tracking decisions made during PR #770 review and triage (2026-05-23). + +--- + +## Product Facts + +### Annotate Mode + +- A user can annotate a document. +- A user can annotate a URL. +- A user can annotate a folder. +- A user can annotate any of the above asynchronously across multiple docs/agents. +- A user can submit/flush annotations through to the agent. +- An agent can, but may not, create new versions of the document. +- If an agent does, a user should be notified. +- Agent revisions may change the state of a document. If it does, a user is notified. +- The user should be able to see those new document versions. Diff mode should allow them to see previous. +- Annotation mode has a gating process, by default it is not used. If it is used, we should assume the agent will iterate with the user until an approval. +- The gating process is similar to the planning flow. +- If an agent gates a document the user already has open, the gate buttons would appear. +- Otherwise, the normal button set appears. + +### Code Review Mode + +- A code review session can be associated with a project (local dir) or GitHub PR or GitLab MR. +- In local mode, there is the possibility of the diffs changing under the session. +- When I review code, I can make annotations. +- I want to send/flush the annotations to the agent, or I can publish to GitHub/GitLab comments (if in PR mode). +- Code review no longer needs to end. +- If an agent session initiates a new code review session from same directory, ideally it would open in the existing session. +- But I would need to be notified of this. +- I would need to be notified if diffs change. +- In legacy tab mode, code review should show the full-screen completion overlay (countdown + close tab) after sending feedback, same as plan review. The inline banner is for embedded mode only. +- When a new diff arrives, files I've already viewed should stay hidden — unless the file actually changed in the new diff. Only show it again if the content is different. + +### Architecture + +- The `plannotator` binary is the only server. There is one server, one frontend, many entry points. +- The daemon starts on install and is always running. Every CLI command assumes a daemon exists and talks to it. No lazy startup, no fallback to local file reads. If the daemon isn't there, it's a bug in the install — not something the CLI works around. +- The binary either starts a daemon or connects to one already running. The daemon serves the frontend. That's it. +- 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 review a plan" into "shell out to the `plannotator` binary." +- The daemon server is the single source of truth for feedback prompt generation. When a user submits feedback (annotate, review), the server composes the final agent-ready prompt and returns it as `result.prompt`. Extensions and the CLI pipe it through — they do not rebuild or reformat the prompt. The only exception is plan mode prompts, which are host-specific (each extension has its own planning workflow). +- The new frontend (`apps/frontend/`) is the only UI going forward. + +### Cross-Cutting + +- Every annotate session lives forever once created — single file, folder, last message, URL. No one-shot sessions. The tab stays open and interactive after feedback is sent. There are no exceptions. +- Folder annotate sessions are reusable. If the user annotates the same folder twice, the daemon should find the existing session and reactivate it — not create a new one. The match key is the folder path. There's no content to update (it's a file browser), but the session is reused as-is. +- Annotate-last is not reusable — "last message" has no stable identity across invocations. Each annotate-last creates a new session. This is fine; the command is rarely run twice in a row. +- Annotate-last flow: caller captures the last assistant message → pipes it to the binary as `markdown` → the daemon holds the original message for the session lifetime → user annotates in the browser → server composes the final prompt including an excerpt of the original message → prompt returns through the CLI → back to the calling agent. The server always anchors annotate-last feedback to the original message because the conversation may have moved on by the time the user submits. +- Legacy tab mode is the only case where the tab closes after feedback — that's the full-screen overlay with countdown, and it's the expected legacy behavior. +- Sessions do not time out. A session, once created, lives until the daemon restarts. We do not kill sessions on a countdown. If a user submits feedback and the agent never comes back, that's for the user to see and decide — not for us to silently clean up. +- We should collect the right data (timestamps, feedback-sent-at, last-agent-contact) so we can eventually show the user: "you submitted feedback but it never came back." But that's a future UI concern, not a reason to expire sessions. +- When a revision arrives (plan, annotate, or review), any external annotations (lint results, agent comments) from the previous version must be cleared. They reference old content with wrong positions. +- `waitForResult` must return immediately if the result is already available — for both `idle` and `awaiting-resubmission` sessions. No consistency gaps. +- Plan/annotate actions (Approve, Deny, Send Feedback) must be disabled while awaiting resubmission. The agent already has the feedback — submitting again against stale content is wrong. Code review already handles this (buttons hidden when idle). +- Late WebSocket subscribers (tab refresh during awaiting) should receive the current state. The snapshot provider for `session-revision` must return the latest content, not null. +- HTML and markdown annotation should go through the same functional pipeline. The `--render-html` path diverges from markdown in a way that `updateContent` can't reach — `updateContent` must also update `rawHtml` for HTML sessions. +- PR review sessions that get reactivated refresh their PR metadata (head SHA, ref, diff baseline, stack info) inside the session closure, so platform actions (approve/comment) target the **current** head commit — not the SHA captured at session creation. Fixed in #816. Note the submit path resolves the head SHA from `prSwitchCache` (keyed by PR url), so that cache entry is refreshed on reactivation too, not just the `prMetadata` variable. +- Annotate history slug is computed once from the initial heading and doesn't update if the heading changes. Acceptable — versions stay intact, just filed under the old name on disk. +- The decision listener must stay alive after every user action — approve, deny, exit, send feedback. If the listener shuts down after approve/exit, the session looks alive but can't respond to future resubmissions. The agent hangs forever. +- Session collisions across worktrees of the same repo are not a real concern. This is a local app — one daemon per machine. + +--- + +## Decisions + +### Decision 1: Code review sessions are long-lived + +**Status:** Implemented + +Code review sessions use a new `"idle"` daemon status. The flow: + +``` +agent → plannotator review (CLI opens, blocks) → session active +session → user annotates → sends feedback → submit (CLI closes) +session → idle (user can browse and annotate, but no submit buttons — nobody is listening) +agent → plannotator review again (CLI opens) → reactivates the idle session +(repeats indefinitely) +``` + +Key behaviors: +- After feedback: session transitions to `idle` via `store.idle()`. The HTTP handler stays alive, resources stay alive. The user can browse the diff and make annotations, but Send Feedback / Approve buttons are hidden (no agent to receive them). +- On reactivation: the agent triggers `plannotator review` again — either from the same directory/branch (local review, matchKey `review:project:branch`) or for the **same PR/MR URL** (matchKey `review:${prUrl}`, e.g. after pushing new commits to the PR). The daemon finds the idle session by matchKey, pushes the new diff via `updateContent`, and calls `store.reactivate()`. The frontend receives a `session-revision` WebSocket event, updates the diff, and re-shows the submit buttons. **In PR mode, `updateContent` also refreshes the PR metadata (head SHA, ref, diff baseline) — and the `prSwitchCache` entry the submit path reads — so a subsequent approve/comment targets the new head commit (#816), instead of the SHA captured when the review first opened.** +- Infinite cycle: this repeats as many times as needed. No counter, no limit. +- Cleanup: idle sessions have no expiry. They live until daemon restart. + +**Resolved questions:** +- Notification when diffs change: agent-triggered via `session-revision` event. No file watcher (user can manually switch diff type to refresh). +- Subsequent feedback without agent: not possible — submit buttons are hidden while idle. +- Cleanup: sessions persist until daemon restart (no TTL on non-terminal sessions). + +### Decision 2: All annotate sessions are persistent + +**Status:** Implemented + +Every annotate session lives forever — single file, folder, URL, last message. No one-shot sessions. All annotate types use `registerPersistentDecision`, which never calls `store.complete()`. The session always suspends and the loop continues. + +Single-file annotate is revisable: it has a matchKey, updateContent, and version history. The frontend shows "Waiting for agent to revise..." after feedback. + +Folder annotate is reusable: it has a matchKey (`annotate:project:folder:path`) and updateContent. Running the same folder command reactivates the existing session. No content to push (it's a file browser), but the session reactivates and the frontend clears the "Feedback sent" state. + +Annotate-last and URL annotate are non-reusable: no matchKey (no stable identity). The frontend shows "Feedback sent" after feedback. The session stays interactive — the user can keep browsing and send more feedback. + +### Decision 3: "Feedback sent" state should be calm, not loading + +**Status:** Implemented (code review), pending (plan/annotate) + +**Code review:** After sending feedback, the `CompletionBanner` shows a green checkmark with "Feedback sent / Your annotations were delivered to the agent." The banner persists until the agent reactivates (no auto-dismiss). Submit buttons disappear. The session stays browsable. + +**Plan/annotate:** Still uses the amber spinner "Waiting for agent to revise..." variant. This should eventually be made calmer, but it's lower priority because plan/annotate persistence works correctly (agent WILL resubmit). + +**What this means for the current code:** +- Code review uses `'feedback-sent'` CompletionBanner variant (green checkmark, not spinner) +- Plan/annotate still uses `'awaiting'` variant (amber spinner) — acceptable for now +- For plan/annotate: actions should be disabled until the revision arrives (the agent already has the feedback and is working — re-submitting before the revision arrives doesn't make sense) +- For code review: different model, TBD based on Decision 1 + +### Decision 4: Hot loop prevention for non-agent origins + +**Status:** Resolved + +The `registerDecisionLoop` spin guard uses promise identity (`currentPromise === lastPromise`) to detect when no new cycle was started. When a non-agent origin calls `resolveAndCycle`, it resolves without calling `startNew()`, so the loop sees the same promise and exits cleanly. No hot loop. + +### Decision 5: Clear external annotations on revision + +**Status:** Implemented + +All three `handleUpdateContent` functions (plan, annotate, review) call `externalAnnotations.clearAll()` before publishing the `session-revision` event. + +### Decision 6: Session expiry + +**Status:** Resolved — sessions don't expire + +Non-terminal sessions (`awaiting-resubmission`, `idle`, `active` after first decision) have `expiresAt` deleted. `cleanupExpired()` skips them. Sessions live until daemon restart or explicit cancellation. + +--- + +## Open Items + +| Item | Severity | Status | +|------|----------|--------| +| External annotations not cleared on revision (all surfaces) | P2 | Fixed | +| Plan/annotate actions not disabled during awaiting | P2 | Fixed | +| `waitForResult` missing `awaiting-resubmission` short-circuit | P2 | Fixed | +| `session-revision` snapshot provider returns null | P2 | Fixed | +| `--render-html` resubmission shows stale HTML | P2 | Fixed — `handleUpdateContent` now accepts and updates `rawHtml` | +| PR reviews keep stale metadata on reuse | P1 | Fixed (#816) — `handleUpdateContent` refreshes `prMetadata`/`prRef`/`prSwitchCache` (+ diff baseline, stack info) on reactivation, so approve/comment targets the current head SHA | +| Gate flag not updated on resubmission | P2 | Deferred — if session was created ungated and agent resubmits with `--gate`, Approve button won't appear (user still sees Send Annotations + Close). Reverse also true: gated session stays gated even if agent resubmits without `--gate`. Fix: `updateContent` should accept and update the `gate` flag. | +| Provenance data for stale sessions | P3 | Deferred — collect timestamps (feedback-sent-at, last-agent-contact) so we can show "you submitted feedback but it never came back." Future UI concern. | +| `onCancel` never wired on awaiting banner | nit | Deferred | +| Session collisions across same-repo worktrees | nit | Accepted — local app, one daemon per machine | +| Annotate slug doesn't update on heading change | nit | Accepted — cosmetic, versions work correctly | +| VS Code editor annotations not cleared on revision | P2 | Fixed — `editorAnnotations.clearAll()` added to `handleUpdateContent` in plan and review servers | +| PR diff scope/baseline not reset on reuse | P2 | Fixed (#816) — `handleUpdateContent` now resets `originalPRPatch`/`originalPRGitRef`/`currentPRDiffScope` on reactivation | +| Remote share link stale on session reuse | P2 | Fixed — all three reuse paths regenerate `remoteShare` before returning the record | +| `sessionRefs` lazy cleanup | nit | Accepted — negligible memory | diff --git a/goals/session-persistence/overview.html b/goals/session-persistence/overview.html new file mode 100644 index 000000000..67f327448 --- /dev/null +++ b/goals/session-persistence/overview.html @@ -0,0 +1,327 @@ + + + + + +Session Persistence — What You Need To Know + + + + + + + +
+

+ feat/session-persistence +

+ +
+ +
+ +
+

+ Session Persistence +

+

+ Denied sessions stay alive. The agent revises. The same session updates in place. No more starting over. +

+
+ + +
+

User Experience

+ +
+
+
+ +

Before

+
+
    +
  1. 1 You review a plan, leave annotations, deny
  2. +
  3. 2 Session dies — completion screen, done
  4. +
  5. 3 Agent revises and resubmits
  6. +
  7. 4 Brand new session — new tab, no context, no diff
  8. +
  9. 5 Start over from scratch
  10. +
+
+
+
+ +

After

+
+
    +
  1. 1 You review a plan, leave annotations, deny
  2. +
  3. 2 Banner: "Waiting for agent to revise..."
  4. +
  5. 3 Agent revises and resubmits
  6. +
  7. 4 Same session updates — diff shows what changed
  8. +
  9. 5 Review again — approve or deny with more feedback
  10. +
+
+
+
+ + +
+

Works across all modes

+
+
+
📋
+

Plan Review

+

Agent revises the plan, session shows plan diff

+
+
+
🔍
+

Code Review

+

Agent makes changes, session refreshes with new diff

+
+
+
✏️
+

Annotate

+

Agent edits the file, session refreshes with updated content

+
+
+
+ + + + + +
+

Session lifecycle

+
+ + + + active + + + deny + + + awaiting-resubmission + + + agent resubmits + + + 10 min TTL + + expired + + + approve + + completed + + + + + + +
+
+ + +
+

How sessions are matched

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModeMatch Key
Planplan:project:slug
Reviewreview:project:branch
Annotateannotate:filepath
+
+

No match (heading changed, different branch, different file) → new session as before.

+
+ + +
+

Files changed — 14 files, +423 / -143

+
+ + Show file list → + +
+ + + + + + + + + + + + + + + + +
daemon-protocol.tsNew status, event family, protocol v2
session-handler.tscreateDecisionCycle, resolveAndCycle helpers
session-store.tssuspend(), reactivate(), matchKey field
session-factory.tsMatching, persistent loop, all three types wired
server.ts (daemon)Skip deletion timer for awaiting sessions
index.ts (plan)Cycle model, updateContent, slug
annotate.tsCycle model, updateContent for files
review.tsCycle model, updateContent for diffs
hook/index.ts (CLI)Accept awaiting-resubmission status
plan-review App.tsxAwaiting state + revision subscription
code-review App.tsxAwaiting state + revision subscription
CompletionBanner.tsxAwaiting variant with spinner
AGENTS.mdDocumentation
+
+
+
+ + +
+

What does NOT persist

+
+
+ + URL-based annotations +
+
+ + "Annotate last message" sessions +
+
+ + Archive sessions (read-only) +
+
+ + Standalone / demo sessions +
+
+
+ + +
+

Recap

+
+
    +
  1. Denied sessions stay alive instead of dying
  2. +
  3. Agent resubmits → same session updates in place
  4. +
  5. Works for plan, code review, and file-based annotate
  6. +
  7. Matching by project+slug, project+branch, or filepath
  8. +
  9. 10-minute timeout if the agent doesn't come back
  10. +
  11. Agent doesn't need to know — matching is server-side
  12. +
  13. Shared createDecisionCycle eliminates duplication
  14. +
  15. Frontend shows amber "waiting" banner with cancel
  16. +
  17. No changes to approve, exit, or standalone flows
  18. +
+
+
+ + +
+

Knowledge check

+
+
+
+ +
+ + + + + diff --git a/goals/session-persistence/overview.md b/goals/session-persistence/overview.md new file mode 100644 index 000000000..a3ea81e2f --- /dev/null +++ b/goals/session-persistence/overview.md @@ -0,0 +1,163 @@ +# Session Persistence — What You Need To Know + +## The User Experience + +### Before + +You review a plan. You leave annotations. You click Deny. Your feedback gets sent to the agent. The session dies. Completion screen. Done. + +The agent reads your feedback, revises the plan, and submits again. A completely new session appears — new tab or new sidebar entry. No connection to the one you just closed. No awareness that this is a revision of the same plan. You start from scratch every time. + +Same story for code review and annotate. Send feedback, session dies, agent makes changes, new session. Every deny-resubmit cycle is a fresh start. + +### After + +You deny a plan. Instead of the completion screen, you see: **"Feedback sent — waiting for agent to revise..."** The session stays alive. Your browser tab stays open. The sidebar shows a pulsing amber indicator. + +The agent revises the plan and submits again. The **same session** updates in place. You see the new plan with a diff showing what changed. Your annotations are cleared (the agent already has them). You review the fresh version. Approve or deny again. Repeat until you're satisfied. + +This works for all three session types: +- **Plan review** — agent revises the plan, session updates with plan diff +- **Code review** — agent makes code changes, session refreshes with new diff +- **Annotate** — agent edits the file, session refreshes with updated content + +### What stays the same + +- Approve works exactly as before +- Exit works exactly as before +- Auto-close works exactly as before +- Sessions opened without an agent (standalone, demo) behave exactly as before — deny is still final +- Sessions do not expire — they persist until daemon restart + +--- + +## Why This Was Needed + +Users and community members repeatedly asked for this. The linear "deny → wait → new session" flow was friction-heavy. Every cycle required the user to re-orient: find the new session, remember what they asked for, compare mentally against the previous version. The plan diff system already existed but couldn't show diffs across sessions — only within a session's version history. + +The deny-resubmit cycle is the core feedback loop of plan-driven development. Making it seamless makes the entire product more useful. + +--- + +## Technical Overview + +### New Session Status: `awaiting-resubmission` + +A non-terminal status in the daemon session lifecycle. The session stays alive — its HTTP handler keeps serving requests, the WebSocket connection stays open, and the frontend connection persists. Sessions do not expire; they persist until daemon restart. + +``` +active → awaiting-resubmission → active → awaiting-resubmission → ... +``` + +### Decision Cycle Model + +Each server (plan, annotate, review) previously used a one-shot promise for the user's decision. Now they use a **cycle model**: every action (deny, approve, exit, send feedback) resolves the current cycle and starts a new one for agent-originated sessions. The decision loop stays alive after all actions. + +Shared helper in `packages/server/session-handler.ts`: +- `createDecisionCycle()` — creates a resolvable cycle with `promise()`, `resolve()`, `startNew()` +- `resolveAndCycle(cycle, result, origin)` — resolves current cycle, starts new one if agent-originated, returns `{ awaitingResubmission: true }` flag + +### Session Matching + +When the agent resubmits, the daemon matches the new request to the existing suspended session using a **match key**: + +| Session Type | Match Key | Example | +|-------------|-----------|---------| +| Plan | `plan:${project}:${slug}` | `plan:plannotator:implementation-plan-2026-05-22` | +| Code Review | `review:${project}:${branch}` or `review:${prUrl}` | `review:plannotator:feat/session-persistence` | +| Annotate (file) | `annotate:${project}:${filePath}` | `annotate:plannotator:/path/to/README.md` | +| Annotate (folder) | `annotate:${project}:folder:${folderPath}` | `annotate:plannotator:folder:/path/to/docs` | + +If a match is found: the session's `updateContent` method pushes new content, the store reactivates the session, and a `session-revision` WebSocket event notifies the frontend. + +If no match (different slug, different branch, different file): a new session is created as before. + +### Content Update + +Each server exposes a `handleUpdateContent` function that: +- Replaces the content in the server's closure (plan text, diff patch, markdown) +- Resets draft state +- Publishes a `session-revision` event to the frontend + +### Frontend + +All three surfaces (plan review, code review, annotate) handle the `awaitingResubmission` response from their feedback endpoints. When received: +- Show the "Feedback sent — waiting for agent to revise..." banner +- Subscribe to `session-revision` WebSocket events +- On revision: refresh content, clear annotations, reset awaiting state + +### CLI + +The CLI binary accepts `awaiting-resubmission` as a valid non-error status. It outputs the denial feedback and exits with code 0 — the agent reads the feedback and replans, same as always. The matching happens server-side; the agent doesn't know about session persistence. + +--- + +## Files Changed + +| File | What changed | +|------|-------------| +| `packages/shared/daemon-protocol.ts` | New `awaiting-resubmission` status, `session-revision` event family, protocol v2 | +| `packages/server/session-handler.ts` | `createDecisionCycle()` and `resolveAndCycle()` shared helpers | +| `packages/server/daemon/session-store.ts` | `suspend()`, `reactivate()` methods, `matchKey` field | +| `packages/server/daemon/session-factory.ts` | `createDecisionScope`, `registerPersistentDecision`, `findAwaitingSession`, matching + reactivation for all three types | +| `packages/server/daemon/server.ts` | Skip deletion timer for awaiting-resubmission sessions | +| `packages/server/index.ts` | Cycle model, `handleUpdateContent`, slug/getSnapshot on session | +| `packages/server/annotate.ts` | Cycle model, `handleUpdateContent` for file-based modes | +| `packages/server/review.ts` | Cycle model, `handleUpdateContent(rawPatch, gitRef)` | +| `apps/hook/server/index.ts` | Accept `awaiting-resubmission` status (exit 0, not error) | +| `packages/plannotator-plan-review/App.tsx` | `awaitingResubmission` state, deny handler check, `session-revision` subscription | +| `packages/plannotator-code-review/App.tsx` | Same as plan review, adapted for diff refresh | +| `packages/ui/components/CompletionBanner.tsx` | `awaiting` variant with spinner and cancel button | +| `AGENTS.md` | Documentation for new status, event family, resubmission flow | + +--- + +## What Does NOT Persist + +- **URL-based annotations** — session stays alive but can't be matched for reuse (source URL might change) +- **"Annotate last message" sessions** — session stays alive but can't be matched for reuse (no stable identity) +- **Archive sessions** — read-only, no feedback cycle +- **Goal setup sessions** — one-shot Q&A, not a review cycle +- **Standalone/demo sessions** — no agent to resubmit + +--- + +## Recap + +1. Denied sessions stay alive instead of dying +2. The agent resubmits → same session updates in place +3. Works for plan, code review, and file-based annotate +4. Matching is by project+slug (plan), project+branch (review), or filepath (annotate) +5. Sessions persist until daemon restart — no timeout +6. Agent doesn't need to know — matching is server-side +7. Shared `createDecisionCycle` helper eliminates duplication across three servers +8. Frontend shows amber "waiting" banner with cancel option +9. No changes to approve, exit, or standalone flows + +--- + +## Quiz + +**1.** What happens to a denied session's HTTP handler? +> It stays alive. `suspend()` does NOT call `disposeResources()` or clear `handleRequest`. + +**2.** How does the daemon know a new plan submission is a revision of an existing session? +> It computes a match key (`plan:${project}:${slug}`) and searches for an `awaiting-resubmission` session with the same key. + +**3.** What happens if the agent changes the plan's heading when resubmitting? +> Different heading → different slug → no match → new session. The old session persists until daemon restart. + +**4.** Does the agent need to track session IDs or know about persistence? +> No. The CLI binary runs fresh each time. Matching is entirely server-side. + +**5.** What's the difference between `suspend()` and `complete()`? +> `complete()` sets terminal status, disposes resources, clears the HTTP handler. `suspend()` sets `awaiting-resubmission`, resolves waiters (so the CLI gets feedback), but keeps everything alive. + +**6.** How does the frontend know the content changed? +> A `session-revision` WebSocket event carrying the new content. The frontend always subscribes in API mode. State resets only fire for live events or when content actually changed (snapshots with unchanged content are ignored to prevent wiping restored state on tab refresh). + +**7.** What happens to a URL annotation session when denied? +> It completes normally (no persistence). URL sources can't be refreshed, so no match key is set. + +**8.** How long does the session wait for the agent to resubmit? +> Indefinitely. Sessions persist until daemon restart — no timeout. diff --git a/goals/session-persistence/provenance.md b/goals/session-persistence/provenance.md new file mode 100644 index 000000000..3f0bf3462 --- /dev/null +++ b/goals/session-persistence/provenance.md @@ -0,0 +1,214 @@ +# feat/single-server-runtime — Full Provenance Record + +PR #733 into `main`. This document tracks every PR in this stack — 13 total across an 8-layer deep chain that was progressively collapsed. + +## The Full Stack + +The original work was an 8-layer stacked PR chain (#734 → #738 → #744 → #753 → #755 → #758 → #759 → #766/#770). Each layer built on the previous. They were squash-merged downward into #734, which landed on `feat/single-server-runtime`. Then 4 more PRs landed directly on the branch. + +``` +main + └── #733 feat/single-server-runtime (OPEN → main) + │ + │ ┌─── Original 8-layer stack (collapsed into #734) ───┐ + │ │ │ + └── #734 daemon runtime ◄─────────────────────────────────┘ + └── #738 debug shell + simulator + └── #744 WebSocket event hub + └── #753 production frontend + initial view + └── #755 embed code review surface + └── #758 embed plan review surface + └── #759 session lifecycle + worktree projects + ├── #766 unified settings + Zustand review store + └── #770 session persistence + │ + │ ┌─── Post-collapse PRs (directly on branch) ─────────┐ + │ │ │ + ├── #797 Remove legacy standalone apps (-32,885 lines) + ├── #801 Simplify extensions to thin wrappers + ├── #806 Start daemon on install + └── #808 Replace ConfigStore with Zustand vanilla store +``` + +## All 13 PRs + +| # | PR | Title | Files | Lines | Base | +|---|-----|-------|-------|-------|------| +| 1 | #734 | Add long-running Plannotator daemon runtime | 331 | +44,032 / -2,314 | feat/single-server-runtime | +| 2 | #738 | Add daemon debug shell and simulator | 296 | +38,855 / -1,266 | ← #734 | +| 3 | #744 | Add daemon WebSocket event hub | 350 | +36,890 / -5,965 | ← #738 | +| 4 | #753 | Add production frontend with initial view | 260 | +33,929 / -461 | ← #744 | +| 5 | #755 | Embed code review surface in frontend app | 237 | +29,834 / -839 | ← #753 | +| 6 | #758 | Embed plan review surface and fix cross-surface issues | 128 | +7,585 / -1,137 | ← #755 | +| 7 | #759 | Session lifecycle, worktree projects, and directory picker | 117 | +7,348 / -921 | ← #758 | +| 8 | #766 | Unified settings, performance optimizations, and Zustand review store | 84 | +4,811 / -706 | ← #759 | +| 9 | #770 | Session persistence: denied sessions stay alive for resubmission | 24 | +1,011 / -170 | ← #759 | +| 10 | #797 | Remove legacy standalone apps, archive, and integrations | 238 | +735 / -32,885 | feat/single-server-runtime | +| 11 | #801 | Simplify extensions to thin wrappers: server-owned prompts, vendor trim, dumb-pipe CLI | 22 | +244 / -315 | feat/single-server-runtime | +| 12 | #806 | Start daemon on install so hooks work immediately | 3 | +24 / -15 | feat/single-server-runtime | +| 13 | #808 | Replace ConfigStore with Zustand vanilla store | 15 | +139 / -152 | feat/single-server-runtime | + +## Net Impact + +442 files changed, +24,920 / -17,622 lines vs main. + +--- + +## PR #734 — Add long-running Plannotator daemon runtime + +**Merged:** 2026-05-27 +**Scope:** 331 files, +44,032 / -2,314 + +The foundational PR. Introduced the daemon architecture: one long-running `plannotator` process per machine that serves the frontend SPA and manages all sessions. + +**What it built:** +- Daemon runtime (`packages/server/daemon/`) — HTTP server, WebSocket hub, session store, state files, lock management +- Session factory — creates plan/review/annotate sessions from plugin protocol requests +- Daemon client — discovery, health checks, start/stop, protocol compatibility +- Single frontend app (`apps/frontend/`) — TanStack Router SPA that mounts plan review and code review surfaces as embedded routes +- Session persistence — sessions survive feedback submission, enter `awaiting-resubmission` status, reactivate on agent resubmission +- Plugin protocol (`packages/shared/plugin-protocol.ts`, `plugin-binary.ts`, `plugin-client.ts`) — typed wire format for binary ↔ extension communication +- Binary discovery and auto-install for Pi and OpenCode extensions +- Smart session opening — daemon decides browser-open vs WebSocket notify based on frontend connection state + +**What it replaced:** +- Old architecture: each hook invocation started a new Bun server on a random port, opened a browser tab, served a standalone HTML file, and died after one decision +- No daemon, no session reuse, no persistent UI + +--- + +## PR #797 — Remove legacy standalone apps, archive, and integrations + +**Merged:** 2026-05-27 +**Scope:** 238 files, +735 / -32,885 + +Deleted ~28,000 lines of code that the daemon architecture made obsolete. + +**What it removed:** +- `packages/editor/` — standalone plan review HTML app +- `packages/review-editor/` — standalone code review HTML app +- `apps/review/` — standalone review server +- `apps/archive/` — plan archive browser +- `packages/shared/integrations-common.ts` and all Obsidian/Bear/Apple Notes integration code +- Legacy standalone server entry points (`packages/server/standalone.ts`, `handleServerReady`, `handleReviewServerReady`) +- Duplicate type definitions, unused exports, stale test fixtures + +**What it preserved:** +- All daemon-backed functionality +- All extension code (Pi, OpenCode, CLI) +- All shared packages used by the daemon + +--- + +## PR #801 — Simplify extensions to thin wrappers: server-owned prompts, vendor trim, dumb-pipe CLI + +**Merged:** 2026-05-28 +**Scope:** 22 files, +244 / -315 (originally 10 commits, grew to 20 through review cycles) + +Moved all feedback prompt generation from 3 client surfaces (CLI, Pi, OpenCode) into the daemon's session servers. Made the CLI a pure dumb pipe. + +**What it changed:** +- Server-owned prompts: plan denied, review approved/denied, annotate file/folder/message — all composed server-side and returned as `result.prompt` +- CLI removed all prompt function imports, Jina config resolution, review arg parsing +- Pi vendor trim: 20+ vendored files → 9 (replaced full arg parsers with `includes()` checks, raw-args binary calls) +- OpenCode removed local prompt composition +- Improve-context moved from CLI local file reads to daemon HTTP endpoint (`/daemon/improve-context`) +- Annotate-last anchoring: server composes blockquoted excerpt of original message +- Plugin protocol version bumped to 2 for the `prompt` field contract change +- `--json` and `--hook` annotate output preserves raw feedback (not composed prompt) for backward compat +- Gemini plan file path threaded through daemon to restore `planFileRule` guidance + +**Review cycles:** 5 rounds of Plannotator review, 1 interrogation (4 models), multiple self-reviews. Key fixes caught by review: +- `emitAnnotateOutcome` ignoring `result.prompt` (bug) +- `ensureDaemonClient` calling `process.exit` instead of throwing under `bestEffort` (bug) +- `cleanupDaemonStateForSessionCommand` not respecting `bestEffort` (bug) +- Plan file rule regression for Gemini (regression) +- `--json`/`--hook` output format change (regression) +- Dead code cleanup (2 unused Pi functions, duplicated inline types) + +--- + +## PR #806 — Start daemon on install so hooks work immediately + +**Merged:** 2026-05-28 +**Scope:** 3 files, +24 / -15 + +Install scripts now stop any existing daemon before replacing the binary, then start a fresh one after. + +**What it changed:** +- `scripts/install.sh` — `daemon stop` (silent) before `rm`/`mv`, `daemon start` (backgrounded) after +- `scripts/install.ps1` — `daemon stop` with `-PassThru` and 10s `WaitForExit` timeout before `Move-Item`, `daemon start` fire-and-forget after +- Backlog items #6 (smart session opening) and #14 (daemon on install) marked DONE + +**Why it matters:** +- The `improve-context` hook fires on `EnterPlanMode` before any session exists — needs a running daemon +- Windows exe file locking requires stopping the daemon before replacing the binary +- Unix upgrades need daemon cycling so the old daemon doesn't serve stale code from memory + +**Review finding fixed:** Windows `-Wait` with no timeout → replaced with `WaitForExit(10000)` + `Kill()` fallback + +--- + +## PR #808 — Replace ConfigStore with Zustand vanilla store + +**Merged:** 2026-05-28 +**Scope:** 15 files, +139 / -152 + +Performance fix: the hand-rolled `configStore` broadcast to all ~60 subscribers on any setting change. Zustand's selector-based subscriptions ensure components only re-render when their specific key changes. + +**What it changed:** +- `packages/ui/config/configStore.ts` — `ConfigStore` class → `createStore` from `zustand/vanilla` with flat state + `get`/`set`/`init` actions +- `packages/ui/config/useConfig.ts` — `useSyncExternalStore` → `useConfigStore(selector)` +- `useConfigValue('key')` signature unchanged — zero consumer API changes +- Cookie persistence and 300ms debounced server sync identical +- 8 consumer files: `configStore.set()` → `configStore.getState().set()` +- `AnnotationPanel` — passes `isMe` as prop to child card components (memo-safe) +- `ReviewSidebar` — subscribes to `displayName`, uses it directly instead of `isCurrentUser()` + +**Review findings fixed:** +- `ReviewSidebar` missing `displayName` subscription (3 models caught this) +- `set`/`get` Zustand parameter shadowing renamed to `setState`/`getState` (2 models) +- `AnnotationPanel` subscription moved from fragile parent-level hook to proper prop flow (1 model) + +--- + +## Architectural Facts Established + +Documented in `goals/session-persistence/decisions.md`: + +1. One binary, one daemon, one frontend, many entry points +2. Daemon starts on install and is always running +3. Server is single source of truth for feedback prompt generation +4. Extensions are thin wrappers — they pipe `result.prompt`, never rebuild prompts +5. Sessions never die — no timeouts, no auto-cleanup +6. Annotate-last anchors feedback to the original message via server-composed excerpt +7. Plan mode prompts are the exception — host-specific (Pi phases, OpenCode line numbers) + +## Post-Stack Work (after the 13-PR stack) + +After the stack collapsed into #733, four review-driven PRs plus a `main` merge landed on the branch: + +| PR | Title | Result | +|----|-------|--------| +| #813 | AddProjectDialog → Radix Dialog | merged | +| #814 | GitLab support: custom-domain detection + MR list/detailed | merged | +| #815 | Fix order-dependent PR-stack grouping (leaf-rooting) | merged | +| #816 | Fix PR review reactivation posting against stale head commit (the P1) | merged | + +Each ran the full validate → adversarial-review → fix loop (8-model interrogations on #801/#808; per-PR code-review + interrogate on the batch). Then: + +- **`main` merged into the branch** (origin/main `0d86d0ee`), clearing the long-standing PR #733 conflicts. Done on an isolated `merge/main-into-ssr` branch via an analyze → port → verify workflow, then fast-forwarded in. It carried main's 35 commits **and** hand-ported three features that lived in deleted code — #763 Ask-AI-in-plan/annotate, #795 `PLANNOTATOR_DATA_DIR`, #792 Windows Pi shim — without dropping main's work or regressing the new system. Inventory: `goals/merge-to-main/carry-in-inventory.md`. +- **Amp plugin** refactored to a conforming thin wrapper (`plugin-client`), eliminating its `PLANNOTATOR_READY_FILE` hang; dead pi-server orphan removed. +- **CI green:** fixed stale release smokes (daemon auth token on Linux; IPv4 session URL on Windows) and deleted dead `@vitest/browser-playwright` scaffolding. PR #733 is now conflict-free and mergeable. + +## Open Items (Backlog) + +**Done since this record was first written:** GitLab detection + MR listing (#814), PR-stack order-dependence (#815), AddProjectDialog → Radix (#813), PR-review stale-metadata P1 (#816), configStore → Zustand (#808), daemon-on-install (#806), tab-mode config. + +**Still open:** +- **Performance** — 29 of 39 findings open (`goals/performance/findings.md`, re-inventoried 2026-05-28): code-splitting, session eviction, App.tsx monolith/memoization, lazy diagram libs, memory-leak/poller cleanup. The global-keyboard-registry cleanup folds into this. +- Sidebar design (session grouping by project vs mode) — needs a decision +- Notify the user when a session updates live +- Amp plugin: bundled `dist` for standalone (curl) install +- Minor deferred (`decisions.md`): gate flag on resubmission, stale-session provenance timestamps, `onCancel` on awaiting banner +- Post-merge human verification: eyeball the Add-Project dialog + GitLab dashboard diff --git a/goals/session-presentation/findings.md b/goals/session-presentation/findings.md new file mode 100644 index 000000000..4db724112 --- /dev/null +++ b/goals/session-presentation/findings.md @@ -0,0 +1,518 @@ +# Session Presentation — silent CLI URL + browser-focus detection + +Captured 2026-06-01. Two related gaps in how a freshly-created daemon session is +presented to the user. Neither is a bug in the binary — both are intentional +behaviors from the daemon refactor that don't match user expectation. + +Context discovered while diagnosing "I ran `plannotator review` in my fullscreen +terminal, the CLI said nothing, and no browser tab opened — but the session +*did* get created (3 live sessions on the default daemon, port 59554)." + +--- + +## Finding 1 — Silent CLI: session-creating commands print no URL locally + +> Silent in your terminal today (these should print the URL): +> - plannotator review +> - plannotator annotate +> - plannotator annotate-last / last +> - plannotator setup-goal +> - plannotator copilot-plan / copilot-last +> +> All of these funnel through one function (runDaemonSessionRequest). Today it +> only prints a URL in remote mode — locally it says nothing. Fixing that one +> spot fixes all of them at once. The URL goes to stderr so it doesn't corrupt +> the feedback text that the slash command captures from stdout. + +### Where it lives (dug context — do not lose) + +- **The chokepoint:** `runDaemonSessionRequest()` in `apps/hook/server/index.ts:623`. + Every interactive session command routes through it. +- **Why it's silent locally:** at `apps/hook/server/index.ts:655-659`, it only + writes a human notice when `created.session.remoteShare` exists OR + `daemon.state.isRemote`. In plain local mode neither is true → nothing is + printed. It just blocks on `daemon.waitForResult()` (line 664) until the user + finishes in the browser. +- **Why stderr, not stdout:** the command branches print the *result* (feedback + text) to **stdout** via `console.log` — e.g. review at + `apps/hook/server/index.ts:843` (`console.log(result.prompt ?? result.feedback ?? "")`). + The Claude Code slash command captures that stdout via the `!` bang in + `apps/hook/commands/plannotator-review.md`. A URL on stdout would corrupt the + captured feedback. The existing remote notice already uses + `process.stderr.write` (line 656/658) — follow that precedent. + +### Command branches that create sessions (top-level dispatch in index.ts) + +| Command | Branch line | Creates session via | +| --- | --- | --- | +| `plannotator review` | 825 | runDaemonSessionRequest | +| `plannotator annotate ` | 847 | runDaemonSessionRequest | +| `plannotator annotate-last` / `last` | 874 | runDaemonSessionRequest | +| `plannotator setup-goal` | 1016 | runDaemonSessionRequest | +| `plannotator copilot-plan` | 1065 | runDaemonSessionRequest | +| `plannotator copilot-last` | 1122 | runDaemonSessionRequest | +| `improve-context` (hook) | 1170 | runDaemonSessionRequest | + +**Already fine — do NOT add a URL print to these:** +- **Plugin hosts** (`plugin plan|review|annotate|annotate-last`) go through + `runDaemonBackedPluginRequest` → `runDaemonSessionRequest(req, { pluginError: true })` + (index.ts:693-694) and already hand the URL to the host as JSON via + `emitPluginSessionReady` (index.ts:660-662). OpenCode/Pi/etc. get it. +- **The plan hook** (Claude Code PermissionRequest) and **`improve-context`** are + automated hooks: stdout is reserved protocol JSON and no human is watching the + terminal. They should stay quiet. So the URL print must be gated to the genuine + interactive commands, NOT applied blindly to every `runDaemonSessionRequest` + caller. The `pluginError` flag distinguishes plugin callers; the hook callers + need their own gate (e.g. an `announceUrl`/interactive opt-in flag). + +### Fix shape (not yet implemented) + +In `runDaemonSessionRequest`, after `created` is obtained and for the local, +interactive, non-plugin path, `process.stderr.write` a one-liner like: +`Review session ready → http://localhost:/s/`. One change covers all +six human commands. Gate it so hooks (`improve-context`, plan hook) stay silent. + +--- + +## Finding 2 — Browser "is anyone watching?" detection is focus-blind + +The daemon decides whether to open a NEW browser tab or stream the session into +an EXISTING tab based on whether a frontend reports itself "visible." That +visibility signal is `!document.hidden`, which is true whenever the tab is the +selected tab in a non-minimized window — **even if the browser is on another +macOS Space / behind the terminal / not the focused app.** So in a fullscreen +terminal, the browser (on its own Space) still says "I'm visible," the daemon +assumes the user is watching, and it drops the session into that off-screen tab +instead of opening a new one. User sees nothing. + +> Short version: the browser hands JavaScript a built-in check — +> document.hasFocus(). It's true only when that browser window is the one your +> Mac is focused on. Fullscreen terminal focused → the browser page's +> hasFocus() is false. That's the signal we're missing. +> +> How it works in practice: +> +> Right now the page reports "am I visible?" as just !document.hidden (is this +> the active tab, not minimized). I add the focus check on top: +> +> // before +> client.sendClientState(!document.hidden, ...) +> +> // after +> client.sendClientState(!document.hidden && document.hasFocus(), ...) +> +> Then I wire it to fire the moment you switch apps, by listening to the +> window's focus and blur events (today it only listens to tab +> visibilitychange): +> +> window.addEventListener("focus", handleChange); // you clicked into the browser +> window.addEventListener("blur", handleChange); // you clicked away to the terminal +> +> So the live flow becomes: +> - You're in your fullscreen terminal → browser fired blur a while ago → page +> already told the daemon "not focused" → daemon sees nobody's watching → +> opens a new tab. +> - You're looking at the browser → focus fired → page says "watching" → daemon +> streams the session into that tab, no new window. +> +> It's a real, well-supported API built exactly for "is this window the one in +> front?" — not a hack. +> +> The one honest caveat, same as before: once this is on, if you're reading the +> browser and then click back to your terminal, the next review opens a new tab +> — because as far as the machine's concerned, you walked away from the browser. + +### Where it lives (dug context — do not lose) + +**Server side (the open-vs-stream decision):** +- `packages/server/daemon/runtime.ts:100` — `presentSession()`. The gate: + ```js + if (!config.legacyTabMode && frontendState.connected && frontendState.anyVisible) { + // publish session-notify over WS → stream into existing tab + return "notified"; + } + // else openBrowser(url) → "opened" + ``` +- `packages/server/daemon/event-hub.ts:108-119` — `getFrontendState()`. `anyVisible` + is true if ANY authenticated connection has `conn.tabVisible === true`. +- `packages/server/daemon/event-hub.ts:168-175` — the `client-state` WS message + sets `connection.tabVisible = message.visible` (and `activeSessionId`). +- `tabVisible` defaults to `true` on connection open (event-hub.ts:143). + +**Frontend side (the source of `visible`):** +- `apps/frontend/src/daemon/events/event-stream.ts:88-89` — `sendClientState()` + calls `client.sendClientState(!document.hidden, currentActiveSessionId)`. + **This is the line to change** to `!document.hidden && document.hasFocus()`. +- `apps/frontend/src/daemon/events/event-stream.ts:92-101` — `handleVisibilityChange` + + the single `document.addEventListener("visibilitychange", ...)`. Need to ALSO + add `window` `focus`/`blur` listeners and remove them in `stop()` (line 139-141). +- `apps/frontend/src/daemon/events/hub-client.ts:236-238` — `sendClientState(visible, activeSessionId)` + emits `{ type: "client-state", visible, activeSessionId }`. + +**Coupled behavior to re-check when changing the signal:** +- `event-stream.ts:95` and `event-stream.ts:110` — the `onSessionNotify` / + `pendingNotifications` buffering ALSO gates on `!document.hidden` (a hidden tab + buffers the notify toast until it becomes visible again). If "watching" now + means focus too, these `!document.hidden` checks should likely move to the same + combined `isWatching()` predicate so the toast logic stays consistent — i.e. + factor out `const isWatching = () => !document.hidden && document.hasFocus()` + and use it in all three spots. + +### Alternative considered +`legacyTabMode: true` in `~/.plannotator/config.json` — always opens a new tab, +no smarts. Blunt fallback; keep as the escape hatch but the focus fix is the +real answer. + +### Honest tradeoffs / open edge cases (for the recon dive) +- Click browser → click back to terminal → next session opens a new tab (you + "walked away"). Arguably correct, but a behavior shift. +- Second monitor: browser focused on monitor 2 while you work on monitor 1 — the + browser IS the focused window, so `hasFocus()` is true → streams into it (no + new tab). Is that what we want? Probably fine. +- DevTools focused: in some browsers `document.hasFocus()` returns false when + devtools has focus even though the page window is frontmost. Minor. +- Multiple tabs/connections: `anyVisible` is an OR across all connections. If you + have two Plannotator tabs and one is focused, it still streams. Fine. +- Reliability: `document.hasFocus()` is a long-standing, well-supported API. Not + a hack. Confirm no SSR/initial-load race where it's transiently false. + +--- + +--- + +## Recon conclusions (2026-06-01) + +### Blast radius is tightly contained — verified +- **Server:** `tabVisible` → `anyVisible` is read in exactly ONE place, + `packages/server/daemon/runtime.ts:103` (`presentSession`). `getFrontendState` + has exactly one caller (same line). So changing what `visible` *means* affects + **only** the open-a-new-tab-vs-stream-into-existing decision. It does NOT touch + WS event delivery, session routing, or anything else. (`allActiveSessionIds`, + the other field, isn't even read by the caller.) +- **Frontend:** only THREE readers of `document.hidden`, all in + `event-stream.ts` (lines 89, 95, 110). All three should be replaced by one + shared predicate `const isWatching = () => !document.hidden && document.hasFocus()`. +- **VS Code extension:** does not use `client-state` / `hasFocus` / this WS path + at all — separate code path, zero impact. + +### Architecture facts that make the fix safe +- One daemon WS connection per browser tab, app-global via + `useDaemonEvents` in `apps/frontend/src/app/Layout.tsx:41`. It persists across + routes (dashboard ↔ `/s/:id`), so the focus signal is reported no matter which + surface the tab is showing. +- `hub-client.sendClientState` (`hub-client.ts:236`) silently no-ops if the + socket isn't OPEN. Focus/blur events fired mid-(re)connect are dropped, but the + stream re-sends client-state on every WS `open` (`event-stream.ts:121-124`), so + state self-heals on reconnect. Fine. +- Happy path is unchanged: when you ARE looking at the browser, `hasFocus()` is + true and `document.hidden` is false → still streams into the tab. The new + behavior only triggers when the browser is NOT the focused window. +- Why `hasFocus()` and not rely on `document.hidden` for Spaces: whether a + browser reports `document.hidden=true` for a window on a non-active macOS Space + is browser/OS-dependent and was empirically FALSE in the repro. `hasFocus()` is + the deterministic "is this the frontmost window" signal. + +### The one real product decision (burst tab-spam) +With the focus fix, every session launched from a fullscreen terminal opens a new +tab. Run 3 reviews back-to-back from the terminal → 3 tabs. That is the inverse +of the earlier "all 3 streamed into one tab" behavior. It matches the explicit +ask ("rather open a new tab than nothing"), but bursts could feel noisy. No clean +"focus-and-raise the existing background tab" option exists — browsers can't +reliably raise a tab on another Space from JS, and per-session URLs open new tabs +anyway. Decision to confirm with user, not silently pick. + +### Recommended sequencing / value +1. **Finding 1 (print session URL to stderr) is the higher-value, zero-risk + fix.** It's a safety net for ALL "session went somewhere I'm not looking" + cases (second monitor, Spaces, focus edge cases) — you always get a clickable + URL in the terminal. Ship this regardless. +2. **Finding 2 (focus detection) is the "do what I meant" nicety** with the one + named tradeoff. Small, contained, safe to implement — but the burst-tab + behavior is a judgment call worth a thumbs-up before building. + +### Implementation sketch (when greenlit) +**Finding 1** — in `runDaemonSessionRequest` (`apps/hook/server/index.ts:623`), +after `created`, for the local non-remote non-plugin interactive path, +`process.stderr.write` a ` session ready → ` line. Gate so hooks +(`improve-context`, plan PermissionRequest) stay silent — add an opt-in flag set +by the genuine interactive command branches, or reuse the existing `pluginError` +distinction plus a new `announceUrl` flag. + +**Finding 2** — in `event-stream.ts`: +```js +const isWatching = () => !document.hidden && document.hasFocus(); +const sendClientState = () => client.sendClientState(isWatching(), currentActiveSessionId); +const handleChange = () => { + if (stopped) return; + sendClientState(); + if (isWatching() && pendingNotifications.length && options.onSessionNotify) { + for (const n of pendingNotifications.splice(0)) options.onSessionNotify(n); + } +}; +document.addEventListener("visibilitychange", handleChange); +window.addEventListener("focus", handleChange); +window.addEventListener("blur", handleChange); +// line 110 guard also becomes isWatching() +// stop(): removeEventListener for all three +``` + +## Status — IMPLEMENTED & VERIFIED (2026-06-01) + +Both fixes shipped on `feat/ui2-code-review`. Built clean (`build:hook` + +`--compile`), binary replaced at `~/.local/bin/plannotator`. + +### Finding 1 — session URL print (done) +- `packages/shared/daemon-protocol.ts` — added `browserAction?: "opened" | "notified"` + to `DaemonCreateSessionResponse` (server already sent it at runtime). +- `apps/hook/server/index.ts` — `runDaemonSessionRequest` options gained + `announceUrl?: boolean`; when set (and local, non-remote), prints to **stderr**: + `Plannotator session ready — :\n `. Gated ON via `{ announceUrl: true }` at the 6 + interactive call sites (review, annotate, annotate-last, setup-goal, + copilot-plan, copilot-last). Codex/gemini/claude plan **hooks** (the two + `action:"plan"` callers) and the plugin path stay silent. +- **Verified:** `plannotator review --git` prints the URL line to stderr, + stdout stays empty (slash-command capture uncorrupted). + +### Finding 2 — focus-aware "is anyone watching" (done) +- `apps/frontend/src/daemon/events/event-stream.ts` — added + `isWatching = () => !document.hidden && document.hasFocus()`, replaced all three + `document.hidden` reads with it, renamed `handleVisibilityChange` → + `handleWatchChange`, added `window` `focus`/`blur` listeners (+ removal in + `stop()`). Now a fullscreen terminal (browser unfocused / on another Space) + reports not-watching → daemon opens a new tab. +- **Verify in browser** (user): launch a review from a fullscreen terminal with a + Plannotator tab already open elsewhere → a new tab should open instead of the + session silently streaming into the off-screen tab. + +### Known tradeoff (accepted) +Reading the browser then clicking back to the terminal makes the next session +open a new tab. Burst of N terminal-launched reviews → N tabs. Escape hatch: +`legacyTabMode` unchanged. + +--- + +## Research — why focus detection CANNOT fix fullscreen-Spaces (2026-06-01) + +User repro that broke the focus fix: terminal fullscreen on its own Space, browser +fullscreen on its own Space. Launch from terminal while the active browser tab is +**Plannotator** → session silently streams into the off-screen tab (no new tab). +Launch while the browser is on **x.com** → works (new tab, browser comes forward). + +Web research conclusion: **there is no portable, reliable web API for "is my +window on the active Space / in front" on macOS.** Specifically: + +- **Page Visibility API is designed to flip `hidden` only on tab-switch and + window-minimize** — "the page is the foreground tab of a non-minimized window." + It is NOT specified to detect a window covered by another window or moved to a + background Space/desktop. (MDN; web.dev pagevisibility-intro.) +- **The only mechanism meant to catch "on another desktop" is Chrome's Mac Window + Occlusion** (chromium.org/developers/design-documents/mac-occlusion — an NSWindow + checks if it is "not covered by other windows, or on another desktop"). But it is + **Chrome-only, has latency, and has open bugs** — e.g. Chromium issue 342919175 + "document.visibilityState is always 'visible' on macOS …", and a Chrome 128 / M1 + report of `visibilitychange` not firing. +- **Firefox does NOT reflect occlusion** (`visibilityState` stays visible when + overlaid — Mozilla bug 1712854). Safari likewise does not. +- **window focus/blur on Space-switch is unreliable** across browsers (Apple dev + forums; general reports). The recommended "combine visibilitychange + focus/blur" + advice (bobbyhadz, designcise) improves the overlapping-window case but does not + solve cross-Space fullscreen. + +Net: the `!document.hidden && document.hasFocus()` fix is a genuine improvement for +**overlapping windows on the same Space** (where blur is reliable), but it cannot +beat **fullscreen apps on separate Spaces** — the browser keeps reporting the tab +as visible+focused. Confirmed by elimination from the repro (the tab was connected +and reporting watching, else the daemon would have opened). + +Hard constraint compounding it: **a background web page cannot raise its own +window/Space** (browser security). Only the OS `open ` brings the browser +forward — which is exactly why scenario A (no Plannotator tab claiming focus → +daemon calls `open`) surfaces the browser and scenario B (notify path) does not. + +### Resolution (chosen) +Because reliable auto-detection is impossible cross-browser, the open-vs-stream +choice must be a **user preference**, not a heuristic. For the cross-Space +fullscreen workflow the correct setting is **`legacyTabMode: true`** — it bypasses +the `anyVisible` check entirely (`runtime.ts:103`) and always `openBrowser(url)`, +so every CLI launch brings the browser forward with a tab (same mechanism that +makes scenario A work). Set in `~/.plannotator/config.json`; `loadConfig()` reads +fresh per session so it is live immediately, no daemon restart. + +Side effect (acceptable / arguably better for this workflow): sessions use the +full-screen auto-closing `CompletionOverlay` instead of the inline +`CompletionBanner`. Revert by removing `legacyTabMode` from config. + +### Follow-up option (not yet done) +Consider flipping the product default so CLI-initiated sessions **open** unless the +user opts INTO dashboard-streaming — because the streaming default depends on a +signal the web platform can't reliably provide, and produces the "nothing +happened" confusion. Streaming only makes sense for users who keep the daemon +dashboard visible on a second monitor; that should be the opt-in, not the default. + +### Sources +- MDN Page Visibility API — developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API +- web.dev — web.dev/articles/pagevisibility-intro +- Chromium Mac Window Occlusion design — chromium.org/developers/design-documents/mac-occlusion/ +- Chromium issue 342919175 (visibilityState always 'visible' on macOS) +- Chrome community: visibilitychange not firing, Chrome 128 / M1 +- Mozilla bug 1712854 (Firefox doesn't reflect occlusion) +- bobbyhadz / designcise (combine visibilitychange + focus/blur) + +--- + +## FINAL DESIGN — daemon surfaces the browser (IMPLEMENTED 2026-06-01) + +The web page can't raise its own window, and its visibility self-report is +unreliable across macOS Spaces. But the **daemon** can bring the browser forward +(`open`/`open -a`/`open -b` switch the OS to the app's Space — proven by the +working "browser on x.com" case). So we stop depending on the flaky "is it +visible?" signal and decide on the **reliable** one: "is a frontend connected?" + +### The rule (in `presentSession`, `packages/server/daemon/runtime.ts`) +- `legacyTabMode` on → always open a new tab (unchanged; also drags in the + full-screen `CompletionOverlay` — intentionally untouched here). +- **Local + a frontend is connected** → reuse that tab: + 1. `activateBrowser()` brings the browser's Space to the front. + 2. publish `session-notify`; the connected tab navigates itself to `/s/` + (`apps/frontend/.../use-daemon-events.ts:26`). + 3. If activation fails (e.g. unknown default browser) → fall through and open + the session URL in a fresh tab. **Never "do nothing."** +- **Local + no frontend connected** → open the session URL (new tab + forward). +- **Remote** → unchanged: notify only when `anyVisible`, else open (prints the + forwarded URL). We can't control a remote user's browser. +- **Always:** the CLI prints the session URL to stderr (Finding 1) as the backstop. + +### Why this fixes the "no" case (browser already on the daemon URL, separate Space) +The connected tab already navigates to the new session on notify — it just sits +on a background Space unseen. The daemon now also **activates** the browser, so +the OS switches to that Space and the user lands on the session in the SAME tab. +No tab spam, never hanging. + +### Browser activation (`activateBrowser()` in `packages/server/browser.ts`) +macOS only (Spaces concept). Brings the browser forward WITHOUT a URL: +- `PLANNOTATOR_BROWSER` = app name / `.app` → `open -a `. +- `PLANNOTATOR_BROWSER` = raw executable path → returns false (no clean + activate-only form) → caller opens the URL. +- Default browser → resolve the https handler bundle id from LaunchServices + (`plutil -convert json` on `com.apple.launchservices.secure.plist`, cached) → + `open -b `. Verified on this machine: `https → com.google.chrome`. +- Non-macOS → returns false (caller opens the URL; Spaces problem is macOS-only). + +### Decoupled from legacyTabMode +This keeps the new inline `CompletionBanner`. The legacy full-screen overlay is +NOT involved. (legacyTabMode's full reach — daemon decision + completion-UI gates +in BOTH `plannotator-code-review/App.tsx` and `plannotator-plan-review/App.tsx` +(banner vs `CompletionOverlay`) + Settings→General toggle + getServerConfig +plumbing — is why it must stay separate.) + +### Files changed +- `packages/server/browser.ts` — `activateBrowser()` + cached + `getDefaultBrowserBundleId()`. +- `packages/server/daemon/runtime.ts` — `presentSession()` rewritten to the rule + above; imports `activateBrowser`. + +### Status: built, server typecheck clean, default daemon restarted. Needs the +user's eyeball on the real cross-Space repro (scenario B) — can't verify browser +activation from here without stealing focus. + +### Open follow-up (not built) +A "quiet mode" opt-in for users who DON'T want focus stolen on every launch +(e.g. agent firing many reviews while they work) — backstopped by the URL print. +Default stays "surface." + +--- + +## FIX 2 — the "active tab is something else" case (cnn.com) — 2026-06-01 + +First cut dropped the `anyVisible` check and activated+notified whenever a +frontend was *connected*. That broke this case: + +- Browser has a Plannotator tab open, but the **active tab is cnn.com**; user in + the terminal launches a command → daemon activates the browser (shows cnn.com), + and the Plannotator tab navigated to the session **in the background, unseen**. + +Root cause: I conflated two different questions. +- **"Is a Plannotator tab the FOREGROUND tab in its window?"** — `document.hidden` + is reliable for this (it's the active-tab signal). → decides reuse vs new tab. +- **"Is the user's window in front (across Spaces)?"** — unreliable from JS; this + is what `activateBrowser()` fixes from the daemon side. + +These are independent. The correct logic uses BOTH: +- `connected && anyVisible` (a Plannotator tab IS the foreground tab) → reuse it: + `activateBrowser()` (local) to bring its Space forward + `session-notify` (it + navigates). The foreground tab is Plannotator, so the user lands on it. +- else (no tab, or the active tab is cnn.com) → `openBrowser(sessionUrl)`: a fresh + **focused** tab. Navigating a *background* Plannotator tab would leave it unseen + behind cnn.com, so we don't — a focused new tab is the only reliable surface + without per-browser tab-activation scripting (AppleScript + TCC permission; + deliberately avoided). + +Also reverted the earlier `event-stream.ts` change: client-state now reports pure +`!document.hidden` again (foreground-tab signal), NOT `&& document.hasFocus()`. +Folding in hasFocus would falsely mark a foreground Plannotator tab as +not-visible when its window is on a background Space — costing a needless new tab +in case (b), which we just confirmed should *reuse* the tab. The focus/blur +listeners were removed too. + +### Verified case matrix (local) +| State | anyVisible | Action | Result | +| --- | --- | --- | --- | +| No Plannotator tab | false | open URL | new focused tab ✓ | +| Plannotator IS active tab, bg Space (case b) | true | activate + notify | reused tab, brought forward ✓ | +| Plannotator is bg tab, cnn.com active (case c) | false | open URL | new focused session tab ✓ | +| Remote + visible | true | notify (no activate) | unchanged ✓ | +| Remote + not visible | false | open (prints URL) | unchanged ✓ | + +Files: `packages/server/daemon/runtime.ts` (presentSession combines both signals), +`apps/frontend/src/daemon/events/event-stream.ts` (reverted to `!document.hidden`). +Built, both typechecks clean, default daemon restarted. + +--- + +## FINAL (shipped) — always open a focused tab. SUPERSEDES the activate-browser design above. + +Diagnostics on the real repro killed the reuse-and-activate approach. Ground truth +from `presentation-debug.log` (separate fullscreen Spaces, user on x.com, a +Plannotator window open elsewhere): + +``` +connected:true, anyVisible:true, connections:[ ... {tabVisible:true, activeSessionId:null} ] +→ branch "notified" + open -b com.google.chrome → user still saw x.com +``` + +**Why reuse is unsalvageable:** `document.hidden` only means "I'm the active tab in +MY window." Every window has an active tab, and x.com is not a Plannotator page so +the daemon never sees it. A Plannotator window on its own Space therefore ALWAYS +reports `tabVisible:true`, regardless of which window/Space the user is actually +looking at. So `anyVisible` ≠ "the user is looking at Plannotator," and activating +the app (`open -b`) raises whichever Chrome window was last in front — often the +wrong one (x.com). No web signal distinguishes these cases. + +**Shipped behavior:** for LOCAL sessions the daemon **always opens the session URL +in a focused tab** (`presentSession` → `openBrowser`). `open ` focuses the +session in the window the user is currently in — the same mechanism that already +worked when no Plannotator tab existed. Tab-per-session is the accepted cost. +Remote keeps the stream-into-visible-tab path. Confirmed acceptable by the user +("it just launches a new tab every time? I'm fine to simplify that way"). + +**Reverted / removed (dead-end reuse machinery):** +- `packages/server/browser.ts` — removed `activateBrowser()` + `getDefaultBrowserBundleId()`. +- `packages/server/daemon/runtime.ts` — `presentSession()` is now just "remote+visible + → notify, else open"; removed activate call + all diagnostics/imports. +- `packages/server/daemon/event-hub.ts` — removed `debugConnections()`. +- `apps/frontend/.../event-stream.ts` — already reverted to pure `!document.hidden`. +- Deleted `~/.plannotator/presentation-debug.log`. + +**Still in place (the keepers):** the CLI prints the session URL on every +interactive launch (Finding 1) — the real backstop. legacyTabMode untouched. + +**Net diff that ships from this whole thread:** Finding 1 (URL print) + Opus 4.8 +models + this one-line-of-intent presentSession ("always open locally"). Everything +about focus/visibility/activation detection was explored and discarded as +unworkable on macOS Spaces. + +### Future opt-in (not built) +"Quiet/dashboard mode": for users who keep the daemon dashboard visible and DON'T +want a new tab per session — stream into the dashboard instead. Needs a real +setting; default stays "open a tab." diff --git a/goals/ui2point0/decisions.md b/goals/ui2point0/decisions.md new file mode 100644 index 000000000..e8de067c1 --- /dev/null +++ b/goals/ui2point0/decisions.md @@ -0,0 +1,37 @@ +# UI 2.0 — Locked Decisions + +> Authoritative decision log for the UI 2.0 work. These resolve the "open forks" from +> `transfer-map.md` §8. Once recorded here, treat them as settled unless explicitly revisited. +> +> Guiding principle: **adopt the prototype's design system and look; keep production's +> feature-complete engine.** The prototype is a visual/UX target, not a feature target. + +--- + +## Locked (2026-05-30) + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| 1 | **Dark-mode polarity** | **Keep dark-first** (`.light` flips). No polarity flip. | Production ships ~50 dark-first themes; flipping is invasive and unnecessary. We adapt ported primitives to our polarity, not the reverse. | +| 2 | **Theme count** | **Keep all ~50 themes.** "Simple" is one addition, not a replacement. | The theme library is a strength worth preserving. New surface/elevation tokens get folded into themes additively. | +| 3 | **Session navigation / sidebar** | **Keep the production sidebar we built** (project → worktree → session, #822). Adopt the prototype's *visual styling*, not its offcanvas model. | The project→worktree→session model is load-bearing (history keying) and richer than the prototype's flat type-grouping. | +| 4 | **Command palette (Cmd+K)** | **Do NOT build.** Out of scope. | The prototype's cmdk palette is not wanted. No Cmd+K work. | +| 5 | **Syntax highlighting** | **Keep highlight.js.** No Shiki swap. | Shiki is heavier/async and touches every code block + diff theming. Not worth it now; revisit only if a concrete need appears. | +| 6 | **Markdown rendering** | **Keep production-grade rendering** (`parser.ts` + `BlockRenderer` + `InlineMarkdown`, with mermaid, graphviz, wiki-links, code-file validation, hex swatches, etc.). | The prototype's markdown is a deliberately minimal prototype. Production is far richer; the prototype is reference-only here. We restyle our renderers, never regress their capability. | +| 7 | **Primitive package boundary** | **Rebuild primitives inside `packages/ui`.** Do NOT vendor `@diffkit/ui`. | Already settled by action. Vendoring pulls heavy deps (shiki/cmdk/etc.) and a light-default polarity that clashes with ours. Our `packages/ui/components/ui/*` is the shared primitive home. | + +## Deferred (no decision now) + +| # | Topic | Status | +|---|-------|--------| +| 8 | **Code-review layout engine** (Dockview vs prototype's tab bar) | **Deferred.** Code-review app, out of current (plan-app) scope. Revisit when we turn to code review. | + +--- + +## What this means for the plan reskin + +We proceed in **production's conventions**: dark-first, all 50 themes intact, highlight.js, our markdown engine, our sidebar. The reskin swaps the plan app's hand-rolled inline Tailwind for the new `packages/ui` primitives + lucide icons — a **styling-layer change only**, no behavior or capability change. + +--- + +*Companion to `legacy-design-state.md`, `prototype-design-state.md`, `transfer-map.md`. 2026-05-30.* diff --git a/goals/ui2point0/header-functionality.md b/goals/ui2point0/header-functionality.md new file mode 100644 index 000000000..3c78a6528 --- /dev/null +++ b/goals/ui2point0/header-functionality.md @@ -0,0 +1,122 @@ +# Production Plan Header — Functionality Inventory + +> Complete catalog of the production plan-review header's behavior, written **before** any +> reskin so nothing is lost. The reskin is a styling-layer swap only — **every item below must +> survive byte-for-byte in behavior.** Source files cited inline. +> +> Decision (2026-05-30): we **keep the "Options" dropdown** (`PlanHeaderMenu`) — it holds +> critical functionality the prototype lacks. We do **not** add the prototype's grid-view button. + +--- + +## 0. Header shell — `packages/plannotator-plan-review/components/AppHeader.tsx` + +- `
` — `h-12`, sticky `top-0 z-50`, `bg-card/50 backdrop-blur-xl`, `border-b border-border/50`. `React.memo`'d. +- **Left:** `headerLeft` slot (the shell's sidebar trigger is injected here) + **Logo** (`AppHeaderLogo`: "Plannotator" wordmark → https://plannotator.ai). +- **Right:** the action/toggle/menu cluster, gap `1`/`md:2`, with `w-px h-5 bg-border/50` dividers between groups (hidden on mobile). +- **Responsive rule throughout:** mobile = icon-only; `md+` = text labels. Must preserve. + +--- + +## 1. App modes — the conditional button sets + +The right cluster renders a **different button set per mode**. All conditions must be preserved exactly. + +| Mode | Condition | Buttons shown | +|---|---|---| +| **Bot callback** | `callbackConfig && !isApiMode && isSharedSession` | divider · **Feedback** (→ bot) · **Approve** (notify bot) | +| **Goal setup** | `isApiMode && !submitted && !linkedDocIsActive && goalSetupMode` | **Exit** (close goal setup) · **Approve** (submit; label = `goalSetupSubmitLabel`, loading "Submitting…", mobile "Submit"; disabled unless `goalSetupCanSubmit`) · divider | +| **Annotate** | `isApiMode && !submitted && annotateMode` (within the shared branch) | **Exit** · (if `hasAnyAnnotations`) **Feedback** "Send Annotations" · (if `gate`) **Approve** "no changes requested" | +| **Normal plan review** | `isApiMode && !submitted && !annotateMode && !goalSetupMode` | **Feedback** "Send Feedback" · **Approve** (ApproveDropdown if OpenCode+agents, else ApproveButton + hover-warning) · divider | +| **After submit** | `submitted === true` | action buttons hidden (toggles + menu remain) | + +Always-present (unless `goalSetupMode`): the **annotation panel toggle**, **AI chat toggle** (if `aiAvailable`), and the **Options menu**. + +--- + +## 2. Action buttons — `packages/ui/components/ToolbarButtons.tsx` + +Shared leaf components; AppHeader calls them ~8 ways. Preserve every prop + state. + +- **`FeedbackButton`** — accent-tinted (`bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25`). + Props: `onClick`, `disabled`, `isLoading`, `label` (default "Send Feedback"), `shortLabel`, `loadingLabel`, `shortLoadingLabel`, `title`, `muted`. + States: normal / disabled / muted. Responsive labels: icon (mobile) → `shortLabel` (md) → `label` (lg). Loading swaps to loading label. +- **`ApproveButton`** — success/green (`bg-success text-success-foreground hover:opacity-90`). + Props: `onClick`, `disabled`, `isLoading`, `label` ("Approve"), `loadingLabel`, `mobileLabel` ("OK"), `mobileLoadingLabel`, `title`, `dimmed`, `muted`. + **`dimmed` state** = `bg-success/50 …/70` (used with the hover-warning below). Mobile shows `mobileLabel`/"OK". +- **`ExitButton`** — muted (`bg-muted text-muted-foreground hover:bg-muted/80`). "Close" / mobile "✕"; loading "Closing…"/"…". + +### 2a. Approve hover-warning — `AppHeader.tsx:227-242` (KEEP) +For `origin === 'claude-code' || 'gemini-cli'` with `showAnnotationsWarning`, the Approve button is wrapped in `group/approve`; on hover a popover appears (`group-hover/approve:opacity-100`, with caret) reading *"{agentName} doesn't support feedback on approval. Your annotations won't be seen."* The button is also `dimmed`. Preserve the wrapper + popover + dimming. + +--- + +## 3. Approve split-dropdown (OpenCode) — `packages/ui/components/ApproveDropdown.tsx` + +Shown only when `origin === 'opencode' && !annotateMode && availableAgents.length > 0`. Replaces the plain Approve button. + +- **Split button:** left = Approve (shows `Approve → {agentLabel}` when an agent switch is set; `(?)` if the saved agent isn't in the list), right = caret toggling the dropdown. Mobile = single "OK" button. +- **Dropdown:** "Switch to agent" section listing `agents` (checkmark on selected) + optional custom entry + "No switch". +- **Persistence:** `getAgentSwitchSettings` / `saveAgentSwitchSettings` (`utils/agentSwitch`) — `switchTo` ∈ agent id / `'custom'` / `'disabled'`, localStorage. Click-outside + Escape close. + +--- + +## 4. Toggles (always present unless goal-setup) + +- **Annotation panel toggle** (`AppHeader.tsx:251-265`) — raw button + inline "comment/panel" SVG. Active = `bg-primary/15 text-primary`; inactive = muted hover. Title toggles Show/Hide annotations. +- **AI chat toggle** (`:266-282`) — only if `aiAvailable`. `SparklesIcon`. Active = `bg-primary/15 text-primary`. **Unread dot** (`bg-primary`, top-right) when `aiHasMessages && !isAIChatOpen`. + +--- + +## 5. Options menu — `packages/ui/components/PlanHeaderMenu.tsx` (the dense one — KEEP ALL) + +Trigger: "Options" button (☰ / ✕ when open), `bg-muted` when open. **Update dot** on the trigger when `updateInfo.updateAvailable && !dismissed` (dismissed on open). Built on `ActionMenu`. + +Menu contents, in order: +1. **Theme switcher** — segmented `light / dark / system` (icons Sun/Moon/System), via `useTheme()` (`setTheme`). +2. **Settings** → `onOpenSettings` (opens the Settings modal — see §6). +3. **Export** → `onOpenExport` (ExportModal). +4. **Agent Instructions** (if `agentInstructionsEnabled`) → `onCopyAgentInstructions` — "Copy agent instructions for external annotations". +5. **Download Annotations** → `onDownloadAnnotations`. +6. **Print / Save as PDF** → `onPrint` ("Choose 'Save as PDF' in the print dialog"). +7. **Copy Share Link** (if `sharingEnabled`) → `onCopyShareLink`. +8. **Import Review** (if `sharingEnabled`) → `onOpenImport`. +9. **Version section** (`MenuVersionSection`) — `appVersion`, update-available state, `origin`, `isWSL`. + +--- + +## 6. Settings modal — `packages/ui/components/Settings.tsx` + `settings/*Tab.tsx` + +Opened from the Options menu. **11 tabs** (`SettingsTab` union): `general · theme · git · display · saving · labels · shortcuts · ai · files · comments · hooks`. Key contents: + +- **general** (`GeneralTab`) — identity (Tater name), Tater mode, UI preferences. +- **theme** — color theme picker (~50 themes via `themeRegistry`), light/dark, **mono font** picker (Fira Code, JetBrains Mono, Hack, IBM Plex Mono, … "Theme Default"). +- **git** — git user name. +- **display** (`PlanDisplayTab` / `PlanGeneralTab`) — plan rendering/display options; diff display (split/unified, scroll/wrap, indicator bars/classic/none, word-alt/word/char, line-bg intensity subtle/normal/strong) for review surfaces. +- **saving** (`SavingTab`) — plan-save enabled + custom path. +- **labels** (`LabelsTab`) — conventional-comment labels (suggestion[blocking], nit, question[blocking], …). +- **shortcuts** — keyboard shortcut reference. +- **ai** — AI providers + models (`aiProviders`). +- **files** (`FilesTab`) — file-linking options. +- **comments** — conventional comments config. +- **hooks** (`HooksTab`) — Plannotator Flavored Markdown reminder; Improvement Hook. + +> The Settings modal is **shared** infrastructure; some tabs serve code review. The header's job is only to *open* it (`onOpenSettings`). Reskinning the header must not alter Settings. + +--- + +## 7. What the reskin will change (band-1 styling only) + +| Element | Reskin action | Preserve | +|---|---|---| +| Action buttons (Feedback/Approve/Exit) | → `Button` primitive (`accent`/`success`/ghost variants) | all props, states, responsive labels, loading | +| Approve hover-warning | restyle popover with tokens | the `group/approve` reveal + dimming | +| ApproveDropdown / PlanHeaderMenu | → `DropdownMenu` primitive (look only) | agent-switch persistence, **every menu item + gating**, theme switcher, version section, update dot | +| Toggles (panel / AI) | → ghost `Button` + lucide icons | active state, unread dot, `aiAvailable` gating, `SparklesIcon` branding | +| Logo | token alignment | the wordmark + link | + +**Not added:** the prototype's grid-view button. **Not changed:** the Settings modal, any handler, any conditional/mode logic in `AppHeader`. + +--- + +*Companion to `decisions.md`, `prototype-design-state.md`, `legacy-design-state.md`, `transfer-map.md`. 2026-05-30.* diff --git a/goals/ui2point0/legacy-design-state.md b/goals/ui2point0/legacy-design-state.md new file mode 100644 index 000000000..e9c448905 --- /dev/null +++ b/goals/ui2point0/legacy-design-state.md @@ -0,0 +1,211 @@ +# Legacy Design State — Complete UI & Styling Anatomy + +> Snapshot of the current (pre–UI 2.0) design system across the shared UI package, the plan-review +> and code-review apps, and the frontend shell. Baseline reference for the `ui2point0` work. +> Branch: `ui2point0` (off `feat/single-server-runtime` @ 2cb76299). + +--- + +## 0. TL;DR — the stack at a glance + +| Layer | Choice | +|---|---| +| Framework | **React 19.2** + TypeScript | +| Styling | **Tailwind CSS v4.1.18** (Vite plugin, **no `tailwind.config.*`** — config is inline CSS via `@theme`/`@source`) | +| Class utils | `cn()` = **clsx + tailwind-merge**; variants via **class-variance-authority (cva)** | +| Primitives | **Radix UI** wrapped shadcn-style in `apps/frontend/src/components/ui/` | +| Theme | CSS-variable token system, **~50 themes**, dark-default + `.light` class; OKLch-based | +| Icons | **Hand-rolled inline SVG** components (plus `lucide-react` available as a dep) | +| State | **Zustand v5 + Immer** (vanilla singleton stores) | +| Routing | **TanStack Router v1** (frontend shell) | +| Layout | Code review uses **dockview-react** (IDE panels); plan review uses custom resizable panels | +| Markdown | custom block parser → renderers; **marked + DOMPurify**, **highlight.js**, **mermaid** + **@viz-js/viz** | +| Animation | CSS keyframes + global 150ms transitions + **motion** (Framer) v12 + **tailwindcss-animate** | +| Fonts | Inter Variable (sans), Geist Mono Variable (mono), Instrument Sans Variable — all via `@fontsource-variable/*` | +| Build | **Vite** + `vite-plugin-singlefile` → one HTML file embedded in the daemon binary | + +**Source-of-truth files:** `packages/ui/theme.css` (tokens + global CSS), `apps/frontend/src/styles.css` (Tailwind entry + `@theme`/`@source`), `apps/frontend/src/lib/utils.ts` (`cn()`). + +--- + +## 1. CSS / Tailwind foundation + +- **Tailwind v4.1.18** via `@tailwindcss/vite` (no PostCSS, no JS config file). Configuration is **inline in CSS**. +- Entry: `apps/frontend/src/styles.css` (~288 lines) — imports fonts, `@plannotator/ui/theme.css`, `@import "tailwindcss"`, `@plugin "tailwindcss-animate"`. +- **Content scanning** via `@source` globs in `styles.css`: + ```css + @source "../src/**/*.{ts,tsx}"; + @source "../../../packages/plannotator-code-review/**/*.{ts,tsx}"; + @source "../../../packages/plannotator-plan-review/**/*.{ts,tsx}"; + @source "../../../packages/ui/components/**/*.{ts,tsx}"; + @source "../../../packages/ui/hooks/**/*.{ts,tsx}"; + ``` + ⚠️ **New `.tsx` dirs outside these globs get no CSS generated** — add a matching `@source` (already called out in CLAUDE.md). +- **Typography scale** customized in `@theme` (text-sm…5xl with explicit line-heights). +- Dark mode is a **custom variant**: `@custom-variant dark (&:not(.light *))` — i.e. dark is the default, `.light` on `` flips it. + +### Per-app CSS entry files +- `packages/ui/theme.css` — token bridge + global base (shared by everything). +- `packages/ui/print.css` — print stylesheet (`.plannotator-print`). +- `packages/plannotator-code-review/index.css` — dockview theming, review comments, conventional-comment labels, file tree, suggestions, code-tour animations, PR-switch loaders. +- `packages/plannotator-plan-review/index.css` — hljs light overrides, annotation highlights, plan-diff line styles. + +--- + +## 2. Theme system (tokens) + +- **~50 theme files** in `packages/ui/themes/*.css` (plannotator [default], dracula, github, nord, tokyo-night, rose-pine, gruvbox, caffeine, terminal, …). +- Each theme defines `.theme-{name}` (dark) and `.theme-{name}.light` (light overrides). Mostly **OKLch** color space (some RGB). +- **~24 semantic tokens** (CSS custom properties): `--background/-foreground`, `--card(-foreground)`, `--popover(-foreground)`, `--primary(-foreground)`, `--secondary`, `--muted(-foreground)`, `--accent(-foreground)`, `--destructive`, `--success`, `--warning`, `--border`, `--input`, `--ring`, `--font-sans`, `--font-mono`, `--radius` (0.625rem), plus theme-specific `--code-bg`, `--focus-highlight`. +- **Tailwind bridge** via `@theme inline` (in `theme.css` + `styles.css`): maps `--color-background: var(--background)` etc., plus radius scale (`--radius-sm/md/lg/xl`), **sidebar tokens** (`--color-sidebar*`), and **surface layers** (`--surface-0/1/2`, derived via `color-mix`). +- Derived extras in `@layer base`: `--card-ring`, `--card-shadow` (different opacity in light/dark), sidebar token aliases. +- Theme is applied **pre-hydration** by a script in `index.html` (flash prevention); persisted via cookies `plannotator-color-theme` / `plannotator-theme`. Managed by `ThemeProvider` (`packages/ui/components/ThemeProvider.tsx`). + +--- + +## 3. Fonts + +- Loaded locally via `@fontsource-variable/*` (no CDN), imported in `styles.css`: + - **Inter Variable** (`@fontsource-variable/inter` ^5.2.8) — sans / body / UI. + - **Geist Mono Variable** (`@fontsource-variable/geist-mono` **pinned 5.2.7**) — code/mono. *(Pin matters: 5.2.8 re-cut metrics broke the landing ASCII banner — see earlier fix.)* + - **Instrument Sans Variable** (`@fontsource-variable/instrument-sans` ^5.2.8) — secondary. +- Stacks: sans = `"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif`; mono = `"Geist Mono Variable", "SF Mono", ui-monospace, monospace`. +- Global `body { font-feature-settings: "ss01","ss02","cv01"; }`. Individual themes can override `--font-sans`/`--font-mono`. + +--- + +## 4. Animations & transitions + +- **Global transition** on `*`: `color, background-color, border-color, box-shadow` @ 150ms `cubic-bezier(0.4,0,0.2,1)`. Suppressed on hidden keep-alive surfaces and before `html.transitions-ready`. +- Full `prefers-reduced-motion` support. +- **CSS keyframes** spread across `theme.css` (`file-flash`, `ai-cursor-blink`, `ai-menu-in`, `goal-pill-in`), `styles.css` (`fade-in`, `slide-in-right`, `toolbar-enter`, `approve-pulse`), and code-review `index.css` (large set for the **code tour**, plus `shimmer-slide`/`block-chase` loaders). +- **JS animation:** `motion` (Framer Motion) **v12.38.0** — used in `BorderTrail.tsx`, `TextShimmer.tsx`, and the tour components (`motion/react`). +- **`tailwindcss-animate` v1.0.7** for `animate-in/out` utilities. + +--- + +## 5. Dependencies (UI-relevant, by purpose) + +**Core:** react / react-dom **^19.2.3**, typescript. +**Styling/variants:** tailwindcss **4.1.18**, @tailwindcss/vite, tailwindcss-animate 1.0.7, class-variance-authority 0.7.1, clsx 2.1.1, tailwind-merge 3.6.0. +**Primitives (Radix):** dialog, popover, tooltip, tabs, dropdown-menu, context-menu, collapsible, checkbox, label, separator, slot (versions ^1.x–^2.x across `apps/frontend` + `packages/*`). +**Layout:** dockview-react **5.2.0** (code review IDE panels), vaul 1.1.2 (drawer), overlayscrollbars(-react) 2.11 / 0.5.6. +**State/routing:** zustand 5.0.13, immer 10.2.0, @tanstack/react-router 1.141, @tanstack/react-table 8.21. +**Markdown/diagrams:** marked 17, dompurify 3.3, highlight.js 11.11, mermaid 11.12, @viz-js/viz 3.25. +**Diff:** @pierre/diffs 1.1.x, diff 8.0.x. +**Annotation/draw:** @plannotator/web-highlighter 0.8.1, perfect-freehand 1.2.2. +**Icons/toasts:** lucide-react 1.14 (available; most icons are hand-rolled), sonner 2.0.7. +**AI SDKs:** @anthropic-ai/claude-agent-sdk, @openai/codex-sdk, @opencode-ai/sdk. + +--- + +## 6. Primitives layer (shadcn-style) + +- Location: `apps/frontend/src/components/ui/` — Radix wrappers: `button.tsx`, `dialog.tsx`, `input.tsx`, `separator.tsx`, `tabs.tsx`, `tooltip.tsx`, `sheet.tsx`, and a large `sidebar.tsx` (~500 lines, context-based layout system). +- `cn()` at `apps/frontend/src/lib/utils.ts`: `twMerge(clsx(inputs))`. +- Variants via **cva** — e.g. `button.tsx` has 6 variants (default/destructive/outline/secondary/ghost/link) × sizes (xxs/xs/sm/default/lg/icon), `Slot` for `asChild`, tokens-driven classes (`bg-primary`, `focus-visible:ring-ring/50`, etc.). +- **Note:** the shared `packages/ui/components/*` library predates this primitives layer and largely uses **hardcoded Tailwind utility strings** rather than the cva primitives — two coexisting styling conventions. + +--- + +## 7. Shared component library (`packages/ui/components/`, 100+ files) + +- **Viewer/rendering:** `Viewer.tsx` (doc + annotation engine), `BlockRenderer.tsx` (block dispatcher), `blocks/` (`CodeBlock`, `TableBlock`+`TableToolbar`+`TablePopout`, `AlertBlock`, `Callout`, `HtmlBlock`, `proseBody`), `InlineMarkdown.tsx`. +- **Annotation system:** `AnnotationPanel`, `AnnotationSidebar`, `AnnotationToolbar`, `AnnotationToolstrip`, `CommentPopover`, `InlineAnnotation`, `FloatingQuickLabelPicker`, `EditorAnnotationCard`. +- **Plan diff:** `plan-diff/` (`PlanDiffViewer`, `PlanDiffModeSwitcher`, `PlanDiffBadge`, `PlanCleanDiffView`, `PlanRawDiffView`, `VSCodeIcon`). +- **Sidebar:** `sidebar/` (`SidebarContainer`, `SidebarTabs`, `FileBrowser`, `VersionBrowser`, `CountBadge`). +- **Settings:** `settings/` (General/PlanGeneral/PlanDisplay/Files/Labels/Hooks/Saving tabs + shared controls). +- **Diagrams:** `MermaidBlock.tsx`, `GraphvizBlock.tsx` (zoom/pan). +- **Image/draw:** `ImageAnnotator/` (Canvas + Toolbar + perfect-freehand), `ImageThumbnail`, `AttachmentsButton`. +- **Modals/overlays:** `ConfirmDialog`, `ExportModal`, `ImportModal`, `DiffTypeSetupDialog`, `CompletionOverlay`, `CompletionBanner`, `PopoutDialog`, `Popover`, `Tooltip`. +- **Misc:** `Toolbar`/`ToolbarButtons`, `ResizeHandle`, `OverlayScrollArea`, `StickyHeaderLane`, `TableOfContents`, `SearchableSelect`, `ActionMenu`, `ModeToggle`, `KeyboardShortcuts`, `UpdateBanner`, `DocBadges`, `ListMarker`, effects (`BorderTrail`, `TextShimmer`), and Tater mascot sprites. + +--- + +## 8. Icons + +- **Hand-rolled inline-SVG React components** (no icon-library dependency for the brand set), each accepting `className` for size and using `currentColor` (or hardcoded brand colors): + - `icons/themeIcons.tsx` (`SunIcon`/`MoonIcon`/`SystemIcon`), `SparklesIcon` (animatable, AI), `ProviderIcons.tsx` (`ClaudeIcon`/`CodexIcon`/`PiIcon`/`OpenCodeIcon` + `PROVIDER_META` map), `GitHubIcon`, `GitLabIcon`, `RepoIcon`, `PullRequestIcon`, `ReviewAgentsIcon`, `plan-diff/VSCodeIcon`. +- `lucide-react` (1.14) is installed and available, but the brand/UI glyphs above are custom. Inline icons in flex rows get `flex-shrink-0`. + +--- + +## 9. Markdown rendering pipeline + +- Parser: `packages/ui/utils/parser.ts` → `parseMarkdownToBlocks()` → flat `Block[]` (heading, blockquote/GitHub-alert, list-item w/ checkboxes, code w/ fenced language, table, hr, html, directive `:::kind`, paragraph). +- `BlockRenderer.tsx` dispatches each type to a renderer (token-styled with `text-foreground/90`, `border-l-2 border-primary/50` blockquotes, etc.). +- **Inline:** `InlineMarkdown.tsx` — 20+ patterns: bold/italic/strikethrough, inline code (or `CodeFileLink` if it resolves to a repo path), bare-URL autolink, ``, `~~del~~`, hex-color **swatches**, `#issue` refs, `@mentions`, `[[wiki-links]]`, images (zoomable), links (routed to linked-doc/code-file/anchor/external), emoji shortcodes, smart punctuation. Code-file links have a **validation gate** (found/ambiguous/missing) with hover preview popovers. +- **HTML blocks:** `marked.parse()` → `DOMPurify.sanitize()` (allowlisted tags/attrs) → `ref.innerHTML` (so `
` DOM state persists), then relative-ref rewrite. +- **Syntax highlighting:** highlight.js, **`github-dark.css`** theme, per-block `highlightElement` (fallback `highlightAuto`), ext→lang map. +- **Diagrams:** mermaid (dark theme config) + @viz-js/viz, both with zoom/pan via viewBox. + +--- + +## 10. Plan-diff visual system + +- **Clean view** (`PlanCleanDiffView`): word-level `/` with `color-mix` success/destructive backgrounds (`.plan-diff-word-added/removed`, `box-decoration-break: clone`); block states `added/removed/modified/unchanged` with hover/annotate rings (`ring-1 ring-primary/30`, `ring-2 ring-accent`). +- **Raw view** (`PlanRawDiffView`): monospace git-style, +/- gutter, line-number column, `.plan-diff-line-added/removed` row backgrounds. +- **Badge** (`PlanDiffBadge`): `+N / -M` in `success/70` + `destructive/70`, active state `bg-primary/15`. + +--- + +## 11. Frontend shell (apps/frontend) + +- **Routing:** TanStack Router (`app/router.tsx`, `routeTree.gen.ts` auto-generated). Routes: `__root.tsx` (wraps `Layout`), `index.tsx` (→ `LandingPage`), `s.$sessionId.tsx` (validates id, loads `getSessionBootstrap`, activates session). +- **Shell:** `main.tsx` → `ThemeProvider` + `RouterProvider`. `app/Layout.tsx` provides `SidebarProvider` + `TooltipProvider`, renders `AppSidebar` + `SidebarPeek` + a `
` that holds the landing `Outlet` **and one absolutely-positioned `SessionSurface` per visited session** (inactive ones use `content-visibility: hidden` + `contain-intrinsic-size` — a "keep-alive tabs" perf pattern). Cmd/Ctrl+, toggles settings. +- **Session embedding:** `components/sessions/SessionSurface.tsx` mounts `ReviewAppEmbedded` or `PlanAppEmbedded` by `session.mode`, wrapped in `SessionProvider`; imports both apps' CSS. +- **Landing/dashboard:** `components/landing/LandingPage.tsx` (~737 lines) — **three-pane translateX carousel**: (0) project selector + `ConjoinedSessionsHistory`, (1) `git-dashboard/GitDashboard`, (2) `FullSessionsHistoryView`. ASCII banner; `ProjectTable`/`ProjectNode` (PRs tab via `buildStacks`, Worktrees tab); `ActiveSessionRow`/`HistoryRow`. +- **Sidebar:** `components/sidebar/AppSidebar.tsx` (project→worktree→session tree, depth indent via `row-style.ts`, Radix collapsible), `SidebarPeek.tsx` (hover-reveal when collapsed), `components/ui/sidebar.tsx` (244px desktop / 260px mobile / 3rem icon, breakpoint 1024px, localStorage `sidebar_state`). +- **Daemon integration:** `daemon/api/client.ts` (`DaemonApiClient`, no auth — daemon is open on localhost), `daemon/events/*` (WebSocket hub → `event-store`, auto-reconnect/polling), `use-daemon-events.ts` (wired in Layout). + +--- + +## 12. UI state (Zustand) + +- **Vanilla singleton stores + Immer** (`enableMapSet()` for Set/Map): + - `stores/app-store.ts` — `activeSessionId`, `visitedSessions`, `expandedProjects: Set`, `collapsedWorktrees: Set`, dialog flags; `activateSession/deactivateSession`. + - `stores/project-store.ts` — projects list, add/remove. + - `stores/history-store.ts` — history entries, lazy fetch. + - `stores/git-dashboard-store.ts` — aggregated PRs (dedup + sort). + - `daemon/events/event-store.ts` — daemon connection state + live sessions (this one uses `create()`, not vanilla). +- **Code-review store** (`packages/plannotator-code-review/store/`): slices `annotations` (hot path) + `diff-options` + `files`, Immer middleware, selectors; provided via `ReviewStoreProvider`. Plus a `ReviewStateContext` to feed static dockview panels (which can't take React props). +- **Config store** (`packages/ui/config/configStore.ts`): settings persisted to localStorage — **the one store NOT on Immer** (recently moved to a Zustand vanilla store). + +--- + +## 13. The two embedded apps + +- **Plan review** — `packages/plannotator-plan-review/App.tsx` (~103 KB monolith). Tree: `ThemeProvider`→`OverlayScrollArea`→ `SidebarContainer/SidebarTabs` (TOC/Versions/Files) + `Viewer` (blocks + annotation toolbar/popover) + right `AnnotationPanel`/`DocumentAIChatPanel`. Features: responsive label mode (ResizeObserver), linked-doc navigation, wide mode, plan diff, export/import, goal-setup surface. +- **Code review** — `packages/plannotator-code-review/App.tsx` (2600+ lines). **dockview** center with panels: `ReviewDiffPanel`, `ReviewAllFilesDiffPanel`, `ReviewPRSummary/Comments/Checks`, `ReviewAgentJobDetail`, `ReviewCodeNav`. Left `FileTree` (React.memo'd nodes), right `ReviewSidebar` (Annotations/AI). Hooks: `useCodeNav`, `useAIChat`, `useReviewSearch`, `usePRSession`, `usePRStack`, `useGitAdd`. Code-tour subsystem with heavy motion. + +--- + +## 14. Build + +- `apps/frontend/vite.config.ts`: plugins = TanStack Router, React, `@tailwindcss/vite`, **`vite-plugin-singlefile`**. Build target esnext, `assetsInlineLimit`/`chunkSizeWarningLimit` 100MB, `cssCodeSplit: false`, `inlineDynamicImports: true` → **one self-contained HTML** verified by `scripts/verify-single-file-build.ts` and embedded/served by the daemon binary. +- Dev: port 3002, proxies `/daemon/*` and `/s/:id/api` to the discovered daemon (`~/.plannotator/daemon.json`, no auth). `bun run dev:frontend`. +- Path aliases: `@` → src; `@plannotator/{code-review,plan-review,ui,shared}` → packages (with `/styles` subpaths to each app's `index.css`). + +--- + +## 15. Characterization & notes for UI 2.0 + +**Strengths to preserve** +- Single token system (`theme.css`) with ~50 themes and clean Tailwind-v4 bridge — theming is centralized and powerful. +- Radix + cva primitives in `apps/frontend/src/components/ui/` are the modern, accessible baseline. +- Strong markdown/diff/diagram rendering already in place. + +**Tensions / debt the 2.0 work will hit** +- **Two styling conventions coexist:** the new `ui/` cva primitives vs. the older `packages/ui/components/*` with hardcoded Tailwind strings. No shared Button/Input/etc. across the shared library. +- **Two monolith `App.tsx` files** (plan ~103KB, review 2600+ lines) concentrate most layout/state — also the contested ground for the deferred keyboard-registry + performance work. +- **Icons are hand-rolled** and scattered as individual components; `lucide-react` is present but underused — no unified icon set/sizing convention. +- **CSS is spread** across `theme.css`, `styles.css`, two app `index.css` files, and `print.css`; animations especially are duplicated across them. +- **`@source` coupling:** any new component directory needs a matching `@source` glob or its classes silently no-op. +- **dockview** (code review) vs **custom resizable panels** (plan review) — two different layout engines. +- Surface/elevation is partly ad-hoc (`--surface-0/1/2`, `--card-shadow`, `bg-muted/30|50` opacities used inconsistently). + +**Open question for 2.0 scope:** unify the primitive layer + icon set + elevation/surface tokens across both apps and the shared library, vs. a targeted reskin on top of the existing structure. + +--- + +*Generated from a parallel read-only exploration of `packages/ui`, `packages/plannotator-plan-review`, `packages/plannotator-code-review`, and `apps/frontend` on branch `ui2point0`.* diff --git a/goals/ui2point0/plan-document-scope.md b/goals/ui2point0/plan-document-scope.md new file mode 100644 index 000000000..bd39fd15b --- /dev/null +++ b/goals/ui2point0/plan-document-scope.md @@ -0,0 +1,136 @@ +# Plan Document — Reskin Scope (production → prototype) + +> The plan *document body* is where production and the prototype diverge most. This scopes the +> transfer: what production does today, what the prototype does, and what actually carries over. +> Companion to `header-functionality.md` (the top bar, already done) and `decisions.md`. +> +> Source reads (cited, 2026-05-30): production `packages/plannotator-plan-review/App.tsx` + +> `packages/ui/components/{Viewer,OverlayScrollArea,StickyHeaderLane,DocBadges}.tsx`; prototype +> `/Users/ramos/oss/diffkit/apps/goal-prototype/src/PlanEditor.tsx` + `styles.css`. + +--- + +## 0. The big picture + +Production and the prototype lay out the *same pieces* (card, metadata, toolstrip, sidebar, panel) +but organize them differently and make different always-on/optional calls: + +| Concern | Production (now) | Prototype (target) | +|---|---|---| +| **Grid background** | **always on** (`.bg-grid` on the scroll container) | **optional, OFF by default** — a Grid3×3 toggle; "legacy look" | +| **Document card** | **always** a card (`bg-card rounded-xl shadow-xl border`) | card by default; in grid mode the card *floats* on the grid | +| **Scrollbar** | **custom** (`overlayscrollbars`, always-visible 10–14px overlay) | **native OS** (`scrollbar-width: thin`) — library dropped | +| **Metadata** (branch·commit·diff·origin) | `DocBadges` absolutely positioned top-left *inside* the card | a context row *inside* the article, above the toolstrip | +| **Toolstrip** | rendered *above* the Viewer card | *inside* the article, grouped clusters, between metadata and blocks | +| **Top bar** | full `AppHeader` (logo + all actions + toggles + menu) | minimal: nav + label + Feedback/Approve + panel/grid toggles | +| **View modes** | `wide` / `focus` — **reshape layout** (hide sidebars/panel) | `default/wide/focus` — **just max-width** (860/1040/720) | +| **Ghost sticky header** | yes (`StickyHeaderLane`) | yes (toolstrip clone on scroll) | +| **Sidebar resize** | `ResizeHandle` drag (no snap-close) | drag-to-close (60% snap) + hover-gutter chevron + edge zone | + +--- + +## 1. Production plan document — current state (cited) + +- **Scroll + card:** `OverlayScrollArea` as `
` (App.tsx:1930) → centered flex `planAreaRef` (App.tsx:1936) → `
` (Viewer.tsx:522), max-width 832/1040/1280 via `planMaxWidth` (App.tsx:1746). +- **Grid:** `.bg-grid` defined in theme.css:105–116; applied **unconditionally** — no toggle. +- **Metadata:** `` absolutely positioned `top-3 left-3` *inside* the card (Viewer.tsx:530) — repo, branch, source, the `+N/−M` `PlanDiffBadge`, amber demo chip, linked-doc breadcrumb. +- **Toolstrip:** `AnnotationToolstrip` rendered *above* the card (App.tsx:1962). Actions cluster (Global comment + Copy) sticky top-right *inside* the card (Viewer.tsx:549). +- **Ghost header:** `StickyHeaderLane` (App.tsx:1943) — duplicate toolstrip+badges, fades in on scroll, 3 responsive states. +- **View modes:** `wideModeType` null/`wide`/`focus` (App.tsx:200). Both wide & focus **hide both sidebars/panel**; wide drops the max-width, focus keeps it. Toggle = small "Wide | Focus" text above the card (App.tsx:2021). +- **Scrollbar:** custom `os-theme-plannotator` (theme.css:257), always visible, 10px→14px on hover, click-scroll. Native (`::-webkit-scrollbar 6px`) only for unwrapped inner scrollers. +- **Sidebar:** `SidebarContainer` (TOC/Versions/Files), `useResizablePanel` 240px (160–400), `ResizeHandle` — **no snap-to-close**. + +## 2. Prototype plan editor — target (cited) + +- **Grid toggle (OPTIONAL, default OFF):** `localStorage "plannotator-grid-view"` (PlanEditor.tsx:352). Grid3×3 button in topbar (585). When ON, *three things change together*: `
` gets `grid-pattern bg-muted` (807); the article becomes a floating card `rounded-xl border bg-card shadow-xl p-5 md:p-8 lg:p-10` (811); the outer wrapper drops to `bg-transparent` (605). Default (OFF) = the outer wrapper is the clean card, article is plain. +- **View modes:** default 860 / wide 1040 / focus 720 px — **max-width only** (808). `w` cycles (456). Subtle toolstrip button; not persisted. +- **3-layer top organization:** + 1. **Minimal topbar** (538): SidebarTrigger + "Plan Review" + Feedback + Approve + divider + panel toggle + grid toggle. + 2. **Metadata row inside the article** (857): branch/repo + `+N/−M` + origin chip. + 3. **Toolstrip inside the article** (871): grouped clusters — input-method (Select/Pinpoint) · annotation-mode (Markup/Comment/Redline/Label) · primary actions (Global/Copy) right-aligned · subtle view-mode button. +- **Ghost sticky header:** toolstrip clone, IntersectionObserver sentinel, `sticky top-2`, fade/translate (815). +- **Sidebar:** drag-to-close at 60% of `SIDEBAR_MIN` (755), hover-gutter chevron (782), thin edge zone when closed (793), `[data-sidebar-panel]` width transition; TOC/Versions/Archive tabs. +- **Scrollbars:** **native** (`scrollbar-width: thin`, styles.css:137) — no `overlayscrollbars`. + +--- + +## 3. Transfer matrix — what carries over + +| # | Item | Decision | Risk / note | +|---|---|---|---| +| T1 | **Grid → optional toggle, default OFF** | **Adopt** the prototype model: Grid3×3 toggle, localStorage, default off, "legacy look". | Behavior change — today's users see grid always; flipping default to OFF is a visible default change. Needs your call on default. | +| T2 | **Document card always present** | **Keep a clean card by default**; grid mode floats the card on the pattern (prototype's dual treatment). | Reconciles with T1; the "3 things change together" pattern (mind the `bg-grid`→`grid-pattern` twMerge gotcha). | +| T3 | **Native scrollbars** (drop `overlayscrollbars`) | **Adopt** native — *this is almost certainly the "remove the custom toolbar/scrollbar" ask.* | Moderate lift: unwrap `OverlayScrollArea`, drop the lib + `os-theme`, re-do layout math (native eats ~15px), retest sticky header / annotation overlays / click-scroll loss. | +| T4 | **Metadata + toolstrip moved INSIDE the article** | **Adopt** the prototype's in-document placement (cleaner, context-aware). | The bigger structural change — touches `App.tsx` + `Viewer.tsx` layout. This is "changed up the toolstrip + the topmost info/action organization." | +| T5 | **Minimal top bar** | **Reconcile:** the prototype's topbar is lean (nav + Feedback/Approve + panel/grid toggles). Production's `AppHeader` carries the full Options menu we decided to keep. → Keep the Options menu; otherwise slim the bar toward the prototype. | We already locked "keep the Options dropdown" (`decisions.md`). | +| T6 | **View modes** | **Decision needed:** keep production's *layout-reshaping* wide/focus (hide sidebars — more powerful), or adopt the prototype's *max-width-only* cycle (simpler)? | Production's is arguably better UX; the prototype's is simpler/cleaner. | +| T7 | **Ghost sticky header** | **Keep** (both have it); align styling. | Low risk. | +| T8 | **Sidebar drag-to-close + hover gutter** | **Adopt** the prototype's drag-to-close (60% snap) + hover-gutter chevron + edge zone. | Replaces `ResizeHandle` interaction; medium lift. | + +--- + +## 4. Decisions — LOCKED (2026-05-30) + +1. **"Custom toolbar" = the custom scrollbar.** → Drop `overlayscrollbars` for native OS scrollbars (T3). Native does what we want; we got carried away with the custom lib. **Regression watchlist (§7) is mandatory.** +2. **Grid → optional toggle, default OFF** (T1/T2). Visible change accepted. +3. **View modes → restructured into a width selector + a separate focus mode** (see §6). Replaces the current "Wide | Focus" label toggle. +4. **Phase it** (T-sequencing): **Phase 1** = native scrollbars + optional grid toggle. **Phase 2** = in-document layout reorg (metadata + toolstrip into the article) + the width-selector/focus restructure (§6). + +--- + +## 5b. (renumbered below) + +## 6. View-mode / width restructure (Phase 2 feature spec) + +Today there are **two** overlapping width systems, and the settings one is **broken**: +- `wideModeType` (`wide`/`focus`) — the above-card "Wide | Focus" toggle (App.tsx:200) that *reshapes* layout. +- A settings `planWidth` (`PLAN_WIDTH_OPTIONS` / `uiPreferences`) → `planMaxWidth` (App.tsx:1746) — **changing it in Settings currently does nothing (bug to fix).** + +**Target design:** +- **Width selector** — a **dropdown/popover next to the Copy-plan button** (in the toolstrip action cluster), NOT the label toggle. Four tiers: + - **Compact** — narrow reading width + - **Default** — inherit the prototype's default (~**860px**) + - **Wide** — wider capped width (~1040px) + - **Ultrawide** (new) — full width / no max-width cap (= production's current `wide` behavior) +- **Focus** — a **separate** mode (distraction-free: hide sidebars/panel), orthogonal to the width tier. Operates at whatever width is selected. +- **Fix the broken settings width** so the tier actually applies (and stays in sync with the dropdown). +- Default tier = **Default (860)**. + +## 7. Scrollbar regression watchlist (Phase 1, mandatory) + +Going native must not regress these known issues: +- **#354 "Can't grab scrollbar"** — the sidebar **resize-handle hit-area overlapped the scrollbar**, making the scrollbar ungrabbable (broke twice). The prototype avoids this with a **gutter** between sidebar and content for the drag handle. → When wiring native scroll + the new sidebar resize, ensure the resize handle's hit area does **not** sit over the scrollbar edge. Test with a mouse directly on the scrollbar. +- **#540 "Safari scroll jumps to top"** — diff view scroll position reset to top each scroll (fixed once; watch for re-introduction when the scroll container changes). Test scrolling + the plan-diff view in Safari. + +## 5. Out of scope / production wins (keep, reskin only) + +Annotation engine (web-highlighter, pinpoint, toolbar), plan-diff word-level system, version history, linked-doc nav, the rich markdown pipeline, the Options menu + Settings, AI chat panel, image annotator. The prototype is the *visual/layout* target; these stay. + +--- + +## 8. HTML rendering — FACT (full-width, never a card, never a grid) + +**HTML content (`HtmlViewer`, the `--render-html` / `.html` path) is ALWAYS full view** — edge-to-edge, +fills the content area, no centered card, no padding/max-width, **no grid ever**. It does NOT get the +plan document's card/grid/embedded treatment. This is settled — do not apply the `gridEnabled` +flat-vs-floating-card logic to HTML. + +**Reference implementation (already built in the `feat/collab` worktree — tied to the multi-doc rooms +feature there; NOT yet on `feat/ui2-plan`):** +- A `fullViewport` mode on `HtmlViewer` toggles three things: + 1. Container → `h-full flex flex-col` (instead of a constrained `maxWidth` wrapper). + 2. iframe wrapper → `flex-1` (instead of `bg-card rounded-xl shadow-xl` — i.e. no card). + 3. iframe height → `100%` (instead of a measured `${iframeHeight}px`). + 4. Action bar (global-comment button) → hidden entirely. +- The parent layout (`App.tsx`) does the heavy lifting via an `isHtmlSurface` flag: switches off the + padding/centering/grid (`min-h-full items-center px-… py-…` → `h-full flex flex-col`) and passes + `fullViewport` + `maxWidth={null}` to `HtmlViewer`. Sticky-actions bar and wide-mode toggle are hidden. + +**Status / sequencing:** the `feat/ui2-plan` branch still has the OLD card-based `HtmlViewer` +(`html-viewer/HtmlViewer.tsx:234`, `shadow-xl`). The full-view behavior arrives via the `feat/collab` +merge — **deferred, do not re-implement here** (would duplicate/conflict). When that lands, HTML is full +view and this scope's grid/embed work simply doesn't touch it. + +--- + +*Reads cited inline. 2026-05-30.* diff --git a/goals/ui2point0/prototype-design-state.md b/goals/ui2point0/prototype-design-state.md new file mode 100644 index 000000000..c32501710 --- /dev/null +++ b/goals/ui2point0/prototype-design-state.md @@ -0,0 +1,287 @@ +# Prototype Design State — The Authoritative UI 2.0 Target + +> Complete anatomy of the DiffKit **goal-prototype** (`/Users/ramos/oss/diffkit/apps/goal-prototype/`) +> plus its design-system packages (`@diffkit/ui`, `@diffkit/icons`). This prototype is the +> **authoritative target** for the UI 2.0 rewrite — not merely a reference. It was heavily refined +> and represents the critical outcome we want production to reach. +> +> Source handoffs: `HANDOFF.md`, `DASHBOARD-HANDOFF.md` in the prototype root. +> Companion doc: `legacy-design-state.md` (current production) and `transfer-map.md` (the diff + plan). + +--- + +## 0. TL;DR — the target stack at a glance + +| Layer | Prototype choice | +|---|---| +| Framework | **React 19.2** + TypeScript (strict, `react-jsx`) | +| Styling | **Tailwind v4.1.18** (`@tailwindcss/vite`, no JS config), inline `@theme`/`@source` in `globals.css` | +| Design system | **`@diffkit/ui`** workspace package — shadcn **"new-york"** style, **28 primitives**, all cva-driven | +| Class utils | `cn()` = `twMerge(clsx(...))` | +| Primitives | Radix UI (v1–v2) wrapped shadcn-style + **cmdk** (command palette) + **vaul** (mobile drawer) | +| Theme | **OKLch** tokens, **light-default (`:root`) + `.dark` class**, single diffkit theme (light+dark), **surface-0/1/2** elevation layers | +| Icons | **`@diffkit/icons`** (hugeicons-react wrapper + 6 custom SVG + brand logos) **and `lucide-react` used directly** throughout the app | +| State | **Plain React `useState` + `localStorage`** (the prototype has **no Zustand** — state is throwaway) | +| Routing | **None** — state-based view switching (`activeSessionId`, `activePR`) | +| Layout — code review | **Simple custom tab bar** (Dockview explicitly abandoned) | +| Layout — plan review | Custom resizable sidebar with **drag-to-close (60% snap)** + hover gutter + `[data-sidebar-panel]` CSS | +| Session nav | **shadcn offcanvas `Sidebar`** (`defaultOpen={false}`) + **Cmd+B/K/1–9** + cmdk command palette | +| Syntax highlighting | **Shiki 4.0.2** (fine-grained 27-lang bundle, dual-theme `diffkit-light`/`diffkit-dark`, cached) | +| Diff rendering | **`@pierre/diffs`** (`PatchDiff`, unified/split, word-alt, line selection) with custom Shiki diff themes | +| Markdown | `@m2d/react-markdown` + `remark-gfm` + `remark-github-blockquote-alert` + `rehype-raw` + Shiki (in `@diffkit/ui`); the plan editor itself uses a **simpler hand-rolled inline parser** | +| Fonts | **Inter Variable** (sans) + **Geist Mono Variable** (mono) via `@fontsource-variable/*` | +| Build | Plain **Vite** dev (`@tailwindcss/vite` + `@vitejs/plugin-react`), pre-hydration theme script in `index.html` | +| Design ethos | **Emil Kowalski principles** — no layout shift, tabular-nums, 44px touch targets, hover guards, no `transition: all`, reduced-motion, no card-in-card | + +**Source-of-truth files:** +- `packages/ui/src/styles/globals.css` — tokens, `@theme` bridge, plugins, scrollbars, Shiki vars, alerts +- `packages/ui/components.json` — shadcn config (new-york, neutral, lucide) +- `packages/ui/src/lib/utils.ts` — `cn()` +- `packages/ui/src/lib/shiki-bundle.ts` / `shiki-themes.ts` / `diffs-themes.ts` — highlighting +- `apps/goal-prototype/src/main.tsx` — the shell (session model + offcanvas sidebar + command palette + view switching) +- `apps/goal-prototype/src/styles.css` — `grid-pattern`, `[data-sidebar-panel]`, drag-disable, `--card-shadow`, reduced-motion + +--- + +## 1. CSS / Tailwind foundation + +- **Tailwind v4.1.18** via `@tailwindcss/vite`, no PostCSS, no JS config. All config inline in `globals.css`. +- Plugins: **`tailwindcss-animate`** + **`@tailwindcss/typography`** (`@plugin`). +- `@source` globs: `../../../apps/**/*.{ts,tsx}` and `../**/*.{ts,tsx}` (broad — whole monorepo apps layer + the UI package). +- **Custom dark variant:** `@custom-variant dark (&:is(.dark *))` — i.e. **light is default (`:root`)**, `.dark` on a parent flips it. *(This is the OPPOSITE of production, which is dark-default + `.light`.)* +- **Typography scale** customized in `@theme` (text-sm…5xl with explicit line-heights, e.g. `--text-sm: 0.875rem / 1.45`, `--text-2xl: 1.375rem / 1.3`). +- **Base layer:** `body { font-size: 13px; font-weight: 450; }`, `code/.font-mono { letter-spacing: -0.02em; font-weight: 450; }`, `* { border-border outline-ring/50 }`. +- **Custom scrollbars:** thin thumb-only (`scrollbar-color: var(--border) transparent`), 6px WebKit, dark-mode hover uses `--surface-2`; `.overflow-stable` utility for layout-shift-free auto-hide. + +### CSS entry layering (prototype) +- `packages/ui/src/styles/globals.css` — the design-system foundation (tokens, primitives base, Shiki, alerts, scrollbars). +- `apps/goal-prototype/src/styles.css` — app-level extras (grid-pattern, sidebar-panel transition, command-palette animations, code-token vars, print). + +--- + +## 2. Theme system (tokens) + +- **OKLch color space** throughout (`oklch(L C H)`). +- **Light-default:** `:root { … }` defines light tokens; **`.dark { … }`** overrides for dark. Class-based, applied pre-hydration by an inline `index.html` script reading `localStorage.theme`. +- **Single theme** (diffkit light + dark) — *not* a 50-theme system. + +**Semantic tokens** (`--background/-foreground`, `--card(-foreground)`, `--popover(-foreground)`, `--primary(-foreground)`, `--secondary(-foreground)`, `--muted(-foreground)`, `--accent(-foreground)`, `--destructive(-foreground)`, `--border`, `--input`, `--ring`). + +**Elevation — the surface layer system (key addition over stock shadcn):** +- Light: `--surface-0: oklch(0.967 …)`, `--surface-1: oklch(0.945 …)`, `--surface-2: oklch(0.925 …)`. +- Dark: `--surface-0: oklch(0.21 …)`, `--surface-1: oklch(0.245 …)`, `--surface-2: oklch(0.28 …)`. +- Convention: `bg-card` = content background, `bg-muted` = page background, `bg-surface-1` = elevated card/row, `bg-surface-2` = hover-on-surface-1. Fractional opacities (`/20 /30 /50`) used liberally for sub-surfaces. + +**Other token groups:** +- `--chart-1` … `--chart-5` (data viz, 5 OKLch hues). +- `--brand` (green `oklch(0.68 0.2 150)`) + `--brand-dev` (yellow) — logo fills. +- `--container` (literal hex override). +- **Sidebar tokens:** `--sidebar`, `--sidebar-foreground/primary/accent/border/ring` (10 vars). +- **Markdown alert colors** (`--alert-color` per kind: note/tip/important/warning/caution) in OKLch, light + dark. +- **Radius:** `--radius: 0.625rem`; bridged to `--radius-sm/md/lg/xl` via `calc()`. +- **Shiki vars:** `--shiki-light` / `--shiki-dark` (token color switch); diff bg auto-switches light→`--background`, dark→`--surface-1`. + +**`@theme inline` bridge** maps every token to Tailwind's `--color-*` namespace (`--color-background`, `--color-surface-0/1/2`, `--color-sidebar*`, `--color-chart-*`, `--color-brand*`, radius scale). + +### App-level token extras (`apps/goal-prototype/src/styles.css`) +```css +:root { + --card-ring: rgba(0,0,0,0.06); + --card-shadow: 0 0 0 1px var(--card-ring), 0 1px 3px 0 rgba(0,0,0,0.04), 0 2px 8px -2px rgba(0,0,0,0.06); + /* --code-keyword/string/number/boolean/comment/property/type/punctuation in oklch */ +} +.dark { --card-ring: rgba(255,255,255,0.08); --card-shadow: …(darker); /* dark code tokens */ } +``` +`--card-shadow` is the workhorse elevation for every content card across all surfaces (`shadow-[var(--card-shadow)]`). + +--- + +## 3. Fonts + +- **Inter Variable** (`@fontsource-variable/inter` ^5.2.8) — sans/body/UI. +- **Geist Mono Variable** (`@fontsource-variable/geist-mono` 5.2.7) — code/mono. +- *(No Instrument Sans — production has a third face the prototype doesn't.)* +- Stacks: sans = `"Inter Variable", "Inter", "Avenir Next", ui-sans-serif, system-ui`; mono = `"Geist Mono Variable", "SF Mono", ui-monospace, "Cascadia Code"`. + +--- + +## 4. Animations & transitions + +- **Reduced motion:** global `@media (prefers-reduced-motion: reduce)` zeroes animation/transition durations + `scroll-behavior: auto`. +- **Sidebar panel:** `[data-sidebar-panel] { transition: width 200ms cubic-bezier(0.16,1,0.3,1); }`, disabled mid-drag via `body[style*="user-select"] [data-sidebar-panel] { transition: none; }`. +- **Command palette:** `cmd-fade-in` (120ms) backdrop + `cmd-slide-in` (200ms, translateY+scale) dialog. +- **Keyframes** in `styles.css`: `fade-in`, `slide-in-right`, `toolbar-enter`, `finding-enter`, `approve-pulse` (expanding ring). +- `tailwindcss-animate` for `animate-in/out` utilities (dialogs, selects, tooltips). +- **No Framer/motion dependency** in the prototype (production uses `motion` v12 for tour/effects). + +--- + +## 5. Dependencies (target inventory) + +**`@diffkit/ui` package:** +- Core: react/react-dom ^19.2, typescript 5.7, tailwindcss 4.1.18. +- Variants/utils: `class-variance-authority` 0.7.1, `clsx` 2.1.1, `tailwind-merge` 3.3.0, `tailwindcss-animate` 1.0.7. +- Radix (14 packages, v1.1–v2.2): alert-dialog, avatar, checkbox, collapsible, context-menu, dialog, dropdown-menu, label, popover, progress, select, separator, slot, switch, tabs, toggle, tooltip. +- Command/overlay: `cmdk` 1.0.0, `vaul` 1.1.2 (mobile drawer), `sonner` 2.0.1 (toasts). +- Markdown: `@m2d/react-markdown` 1.0.0, `remark-gfm` 4.0.1, `remark-github-blockquote-alert` 2.1.0, `rehype-raw` 7.0.0. +- Highlighting: `shiki` 4.0.2, `@shikijs/langs` 4.0.2, `@shikijs/rehype` 4.0.2. +- Typography: `@tailwindcss/typography` 0.5.19. +- Forms/widgets: `react-hook-form` 7.54, `react-day-picker` 8.10, `react-resizable-panels` 3.0.3, `next-themes` 0.4.4. +- Fonts: `@fontsource-variable/inter` 5.2.8, `@fontsource-variable/geist-mono` 5.2.7. + +**Prototype app adds:** `@diffkit/file-tree` (ABANDONED — do not port), `@diffkit/icons`, `@mdxeditor/editor` (abandoned facts-notebook), `@pierre/diffs` 1.1.12, `lucide-react` 0.511. + +--- + +## 6. Primitive layer — `@diffkit/ui` (28 components, the target shared library) + +Location: `packages/ui/src/components/`. shadcn **new-york** style, baseColor **neutral**, CSS-vars mode, icon library **lucide**. Every component cva-driven where it has variants. + +| Component | Notable shape / variants | +|---|---| +| **button** | variants: default/destructive/outline/secondary/ghost/link × sizes **xxs/xs/sm/default/lg/icon**. Built-in **`iconLeft`/`iconRight`** slots, auto SVG sizing, 3px `ring-ring/50`, `asChild` via Slot, base `text-[13px] font-medium rounded-md` | +| **card** | composition (Card/Header/Title/Description/Content/Footer), `rounded-xl border bg-card py-6 gap-6` | +| **badge** | default/secondary/destructive/outline, `rounded-md px-3 py-1 text-xs`, `[&>svg]:size-3`, `asChild` | +| **state-pill** (custom) | `tone`: open(green)/closed(red)/merged(purple)/muted/secondary — `rounded-full px-2 py-0.5 text-xs`, semantic PR/issue state | +| **sidebar** | compound (11 sub-components), context-based, `--sidebar-width: 244px / icon 3rem`, Cmd+B, localStorage persist, mobile→Sheet, variants sidebar/floating/inset, collapsible offcanvas/icon/none | +| **command** | cmdk wrapper — Command/Dialog/Input/List/Empty/Group/Item/Shortcut/Separator; `CommandDialog` top-[20%] max-w-2xl, kbd shortcut rendering | +| **markdown** | `@m2d/react-markdown` + remark-gfm + github-alerts + rehype-raw, **Shiki dual-theme** code, 32 component overrides, CopyButton overlay, GitHub alerts, asset-URL resolver context | +| **markdown-editor** | full comment editor: write/preview tabs, syntax overlay, **@mention autocomplete**, toolbar (⌘B/I/E/K/H/⇧./⇧8/⇧7/tasks), media drop, caret tracking, imperative `insertAtCaret`/`replaceUploadPlaceholder` | +| **dialog** | **responsive** — Radix Dialog desktop / vaul Drawer mobile (<768px), `bg-black/55 backdrop-blur-[2px]`, rounded-2xl | +| **tabs** | TabsList `bg-surface-1 p-px`, trigger active `bg-surface-0 shadow-sm`, nested radius via calc | +| **tooltip** | `bg-surface-2 text-[11px] rounded-md`, animate-in fade/slide | +| **callout** | variants default/info/warning/success/destructive, `rounded-lg px-4 py-2.5` + CalloutContent/Action | +| **logo** (custom) | parametrized SVG squircle (k=0.66) + 3×3 contribution grid, `fill-brand`/`fill-brand-dev` | +| **avatar** | Radix wrapper, `size-8 rounded-full`, AvatarImage/Fallback (`bg-muted`) | +| **breadcrumb** | nav/List/Item/Link/Page/Separator(ChevronRight)/Ellipsis — `text-sm gap-1.5` | +| **input / textarea** | `h-9 rounded-md`, focus `ring-[3px] ring-ring/50`, aria-invalid destructive; textarea `field-sizing-content` | +| **select / dropdown-menu / popover / context-menu** | standard Radix shadcn wrappers (Check indicators, animate zoom) | +| **sheet / drawer / resizable** | Radix Dialog-as-sheet / vaul / react-resizable-panels | +| **spinner / skeleton / progress / switch / checkbox / toggle / label / separator / table / alert / alert-dialog / calendar / sonner / form** | standard shadcn, minimal customization | + +`cn()` (`lib/utils.ts`): `twMerge(clsx(inputs))`. + +**What the prototype primitive layer has that production lacks:** a *single coherent* shared primitive set (production splits a partial `apps/frontend/.../ui/` shadcn layer from the older hardcoded-string `packages/ui/components/*`), plus `state-pill`, `command` (palette), `breadcrumb`, `avatar`, `callout`, `markdown-editor`, `logo`, surface-layer-aware `tabs`/`tooltip`, and button `iconLeft/iconRight` + `xxs/xs` sizes. + +--- + +## 7. Icons + +- **`@diffkit/icons`** (`packages/icons/`): wraps **hugeicons-react** (aliased, 70+ re-exported glyphs) + **6 custom SVG** (ActionsIcon, ArchiveDownIcon, FullScreenIcon, PenIcon, SeparatorHorizontalIcon, StarIcon) + **brand logos** (GitHubLogo, GitHubWordmarkLogo, XLogo, from svgl.app). API: `{ size?, strokeWidth? }` + full SVG props, `currentColor`, self-determining aria. +- **BUT** the prototype app itself imports **`lucide-react` directly** for almost everything (session icons Target/ListChecks/Code2/ScrollText/Home; UI Check/X/ChevronDown/Plus; code-review Bot/Crosshair/MapPin/Layers/FileCode/Locate; etc.). `@diffkit/icons` is barely used in the app surfaces. +- **Net target convention:** lucide-react as the working icon set (consistent sizing via `size={N}`), with a small custom/brand set for things lucide lacks. + +--- + +## 8. Markdown & syntax highlighting + +- **Highlighting is Shiki, not highlight.js.** `shiki-bundle.ts`: fine-grained 27-lang bundle (js/ts/jsx/tsx/json/html/css/bash/python/go/rust/ruby/java/c/cpp/swift/kotlin/php/csharp/yaml/toml/dockerfile/vue/svelte/sql/graphql/markdown/diff), JS regex engine, dual-theme (`diffkit-light`/`diffkit-dark`), HTML cache keyed `lang:code`, shell/alias map. +- **Themes** (`shiki-themes.ts`): hand-tuned token palettes — light keywords `#c41562`, strings `#107d32`, functions `#7d00cc`; dark keywords `#ff4d8d`, strings `#00ca50`, functions `#c472fb`. +- **`@diffkit/ui/markdown.tsx`** is the rich renderer (remark-gfm + github-blockquote-alert + rehype-raw, 32 overrides, alerts, asset resolver). **However the plan editor prototype does NOT use it** — it has its own line-by-line `parseBlocks()` + a regex `Inline()` renderer (bold/italic/code/links only) and calls `createMarkdownHighlighter()` for code blocks. So the prototype demonstrates *two* markdown paths; the production-grade one is `@diffkit/ui/markdown.tsx`. +- **Diff themes** (`diffs-themes.ts`): `quickhubLight`/`quickhubDark` Shiki themes for `@pierre/diffs`, mapping tokens to VS Code color model + ANSI + git decoration colors. + +--- + +## 9. App shell — session model, view switching, navigation + +**No router.** `apps/goal-prototype/src/main.tsx` switches views from two state vars: +```ts +type SessionType = "interview" | "facts" | "plan" | "review"; +interface Session { id; type; title; needsAttention; completed; } +// view = activePR ? "pr-detail" : activeSession ? session.type : "dashboard" +``` +- `SESSION_TYPE_META` maps each type → lucide icon + group label (Goal Setup / Facts Review / Plan Reviews / Code Reviews). +- Dashboard is the default (no session selected). Clicking a PR row sets `activePR` → PRDetail. Selecting a session sets `activeSessionId` → that surface. + +**Offcanvas session sidebar** (shadcn `Sidebar collapsible="offcanvas"`, `SidebarProvider defaultOpen={false}`, `--sidebar-width: 16rem`): +- Header (logo "P" + session count), grouped session menu (by type, completed = strikethrough + green check, needsAttention = primary dot), footer theme toggle. +- **Why offcanvas (handoff rationale):** browsers shipped vertical tabs (2026) → a persistent left rail creates a "double sidebar." Fully hidden by default, slides in as overlay. + +**Keyboard shell:** **Cmd+B** toggle sidebar, **Cmd+K** command palette (cmdk-style modal: search + sections Sessions/Appearance/View/Plan Headings, arrow-nav, kbd hints), **Cmd+1–9** positional session jump. `SidebarTrigger` is always the first element in each view's topbar. + +**Internal sidebars are independent:** the plan editor's TOC/versions/archive sidebar and the code-review file tree are *within-session* tools with their own state — the offcanvas session sidebar overlays on top of everything and is unrelated. + +--- + +## 10. Surface — Plan editor (`PlanEditor.tsx`, ~1425 lines) + +- **Layout:** `flex h-full flex-col bg-muted` → topbar nav → content card (`rounded-xl bg-card shadow-[var(--card-shadow)]`) holding left sidebar | `
` | right annotation panel. +- **Topbar:** SidebarTrigger + "Plan Review" + (Feedback btn, Approve btn green-state-machine, panel toggle, **Grid3x3** grid toggle). +- **Block parser:** custom inline `parseBlocks()` (heading/code/blockquote/list-item/table/hr/paragraph) — NOT production `parser.ts`. Code blocks rendered via Shiki `codeToHtml({ themes: {light:"diffkit-light",dark:"diffkit-dark"} })` with regex fallback. +- **Annotation:** selection toolbar (Comment=blue MessageSquare, Delete=red Trash2) positioned over selection; annotation modes markup/comment/redline/label; pinpoint (Crosshair) block mode; right `AnnotationPanel` (cards with quote + comment, inline edit, copy-all footer). Persisted to localStorage. +- **Sidebar UX (port this exactly):** tabs TOC(List)/Versions(Clock)/Archive(Archive); resizable via gutter with `before:-inset-1.5` hover zone; **drag-to-close** — if drag width `< SIDEBAR_MIN * 0.6` (108px) snaps shut mid-drag; `[data-sidebar-panel]` drives the CSS transition; hover-reveal collapse chevron (`PanelLeftClose`) centered on gutter; thin `w-1` hover zone + `PanelLeftOpen` when closed. `SIDEBAR_MIN 180 / MAX 400 / DEFAULT 240`. +- **Grid view toggle (port this — 3 things change together):** `
` gets `grid-pattern bg-muted`; `
` becomes `rounded-xl border border-border/50 bg-card p-5 shadow-xl md:p-8 lg:p-10`; outer wrapper drops `p-2`→`p-0` and `bg-card shadow-…`→`bg-transparent`. localStorage `plannotator-grid-view`. **Gotcha:** original class `bg-grid` was silently dropped by `tailwind-merge` (conflicts with `bg-muted`) → renamed `grid-pattern`. +- **View modes:** default 860px / wide 1040px / focus 720px (max-width on content). **Ghost header:** sticky toolstrip clone that fades in when the real toolstrip scrolls away (IntersectionObserver sentinel). +- **Body constraints:** `max-w-3xl`, `tabular-nums`, `before:-inset-2` 44px touch targets. + +--- + +## 11. Surface — Code review (`code-review/`, 12 files) + +**The headline architectural decision: a simple state tab bar replaces Dockview.** + +- **Root** (`index.tsx`): `flex h-full` → topbar → 3-panel (file tree aside | center [TabBar + content] | annotation sidebar aside), both asides mouse-resizable. Keyboard: Cmd+Enter submit, Cmd+B tree, Cmd+. sidebar, Cmd+\ focus, `[`/`]`/`j`/`k` file nav, `v` viewed, `a` all-files, `g` go-to-symbol, `1–9` tab, `?` help. +- **Tab model** (`tab-bar.tsx`): `Tab { id, type: "file"|"agent"|"tour"|"code-nav", label, file?, pinned? }`. Tabs open/switch/close; bar hides when ≤1 tab; per-type lucide icon (FileCode/Layers/Bot/MapPin/Crosshair); active = `bg-card` + top accent bar; annotation dot; +N/−M stats on active; close button unless pinned. +- **Diff** (`diff-viewer.tsx`): `@pierre/diffs` `PatchDiff` (unified/split, `lineDiffType:"word-alt"`, `enableLineSelection`, `onLineSelectionEnd`→pending annotation). Theme synced into Pierre's shadow DOM via `unsafeCSS` + MutationObserver on `html.dark`. Header has prev/next, copy-path, viewed toggle, options popover (style/wrap/indicators/line-diff/whitespace/tab-size). +- **File tree** (`file-tree.tsx`, uses abandoned fork — **port the UI patterns, not the fork**): **DiffTypePicker** (7 types: uncommitted/staged/unstaged/last-commit/merge-base/branch/all, each with label+desc), **BaseBranchPicker** (Local/Remote groups, "detected"/"default" badges), **viewed-state circles** (green check vs hollow, replacing file icons), **colored +N/−M** decorations. +- **Annotation sidebar** (`annotation-sidebar.tsx`): 4 tabs — **Notes** (annotations grouped by file, `L{n}` line refs, label badges), **AI** (chat: user/Sparkles avatars, typing dots, autosize textarea), **Agents** (job list + launch panel: provider picker, model/effort/reasoning segmented pickers, Run; status icon map), **PR** (mock PR comments with replies/resolve). +- **annotation-toolbar.tsx:** fixed bottom toolbar — CC label buttons, comment textarea, suggestion code block, image attach, ⌘Enter submit. +- **agent-panel.tsx:** full job view — header (status bg/icon), findings (issue/suggestion/info colored cards), summary box (confidence %), copy/kill, collapsible logs. +- **code-nav-panel.tsx:** symbol def/ref/type results grouped by file, kind badges (def green/ref blue/type purple), filter all/definition/reference. +- **tour-dialog.tsx:** guided stops — progress bar, greeting (stop 0), current stop + file:line, checklist, prev/mark-visited/next. +- **all-files-view.tsx:** sticky jump pills + IntersectionObserver scroll tracking + per-file accordion with inline `PatchDiff`. + +**Data model** (`types.ts`): `ReviewFile {additions,deletions,path}`, `Annotation {id,filePath,lineStart,lineEnd,side,text,label?,createdAt}`, `TreeNode`, and **`CC_LABELS`** (conventional-comment set: praise/nit/suggestion/issue/question/thought, each with semantic color classes). + +--- + +## 12. Surfaces — Goal interview + Facts review + +- **GoalHeader** (text-only, no card — Emil "no card-in-card"): one `h1 text-lg font-semibold`, optional mono `tabular-nums` progress, one `text-[13px] text-muted-foreground` objective line. +- **Interview** (`App.tsx`): vertical collapsible question list; answer modes text/single/multi/multi-custom/custom; status dots (hollow/green-check/yellow-x); recommendation box with "Use"; skip-with-note; Tab/Shift+Tab advance, number-keys toggle options, Esc collapse; localStorage state. +- **Facts** (`FactsReview.tsx`): card list, per-fact accept/edit/comment/auto-verify(FlaskConical)/remove; hover-revealed actions with `@media(hover:hover)` guard; `before:-inset-2` 44px targets; inline textarea edit; auto-verify badge; accept-all. +- **Handoff note:** production's `GoalSetupSurface` (Interview + Facts) is **already more complete** than the prototype (Ctrl+U/K/J, CommentPopover, help dialog). These surfaces are **reference-only** — production wins; just reskin to the new tokens/primitives. + +--- + +## 13. Surfaces — Dashboard + PR Detail (net-new) + +These have **no production equivalent** (production has a `GitDashboard` in its landing carousel, but not these GitHub-grade pages). They define the dashboard look. + +- **Dashboard** (`Dashboard.tsx`): `xl:grid-cols-[minmax(13rem,16rem)_minmax(0,1fr)]` — sticky metric sidebar (SmallMetricCard `rounded-lg px-3 py-2 hover:bg-surface-1`) + grouped PR list (Review requested / Open / Recently merged) with sticky `-top-px` headers. PR row = full-width button `rounded-xl px-3.5 py-2.5 hover:bg-surface-1` with status icon (green/purple/gray/red), title, `repo #n · @author · time`, review badge, `font-mono text-[10px]` +/− stats, comment count. `PullRequest` interface + 8-item `DEMO_PRS`. +- **PR Detail** (`PRDetail.tsx`): `xl:grid-cols-[minmax(0,1fr)_minmax(16rem,20rem)]` — main (breadcrumb→header→body→activity) + sticky `top-10` sidebar. Header: PR icon, title, `@author wants to merge` + branch pills (`rounded-md bg-surface-1 font-mono text-xs`), stats bar (`bg-surface-1`, commits/files/+−, **5-square ratio viz** `h-2 w-2 rounded-sm` green/red, Review button). Body: minimal split-on-`\n\n` renderer (`##`/`###`/`-`/p). Activity: comment thread (avatar initials, reaction pills) + input. Sidebar: Labels (color pills), Reviewers (avatar+login+status), Participants (overlapping `-space-x-1.5` ring-2 stack), Details (key-value `tabular-nums`). + +**Reusable pattern catalog (the dashboard design tokens):** metric cards, PR rows, sticky section headers, count badges (`rounded-full bg-surface-1 px-1.5 py-px text-[10px] tabular-nums`), status pills, label pills, avatar circles (h-6/h-7/h-8), reaction pills, branch pills, diff-ratio squares. Colors: green=open/approved, red=closed/changes, purple=merged, yellow=pending, surface-1/2 + muted-foreground. Single `xl` breakpoint throughout. + +--- + +## 14. State & build (target shape) + +- **State:** the prototype uses **plain `useState` + `localStorage`** only — there is **no Zustand**. This is throwaway prototype state; the production Zustand architecture (vanilla singleton stores + Immer, code-review slices) is the keeper. The prototype informs *what state exists per surface*, not *how to store it*. +- **Build:** plain Vite dev (`@tailwindcss/vite` + React plugin, `esbuild.tsconfigRaw:{}`), path alias `#/*`→`src`, pre-hydration theme script in `index.html` (`localStorage.theme` → `.dark` class), `color-scheme`/`theme-color` metas. Production's single-file embedded-in-daemon build is a separate concern the rewrite must preserve. + +--- + +## 15. Design principles (Emil Kowalski — apply everywhere) + +- **No layout shift:** `tabular-nums` on all counters; no font-weight change on hover. +- **Touch targets:** 44px via `before:-inset-2` pseudo-elements on action buttons. +- **Hover guards:** `@media(hover:hover)` on hover-revealed controls so touch users always see them. +- **No `transition: all`:** every transition names exact properties. +- **Reduced motion:** global `prefers-reduced-motion` zeroes durations. +- **No card-in-card:** GoalHeader is text-only; content card never nests a bordered container. +- **Body cap:** `max-w-3xl` (672px) on reading content. + +--- + +## 16. What the prototype is — and is NOT + +**It IS** the authoritative target for: the **token system** (OKLch + surface layers + card-shadow), the **shadcn `@diffkit/ui` primitive set**, **Shiki** highlighting, the **offcanvas session shell + command palette**, the **code-review tab model** (Dockview replacement), the **plan-editor sidebar drag-to-close + grid view**, the **dashboard/PR-detail surfaces**, and the **Emil design principles**. + +**It is NOT** feature-complete. It deliberately omits / simplifies (production must keep these): the **50-theme** system, the **rich markdown** pipeline (mermaid, graphviz, wiki-links, code-file validation gate, hex swatches), **web-highlighter** + image annotator + external-annotations SSE, the **daemon/WebSocket** runtime + session persistence, the **project→worktree→session** resolution model, **plan-diff** word-level engine, the formal **keyboard-shortcut registry**, **AI session streaming**, and **git-add** staging. The abandoned bits — `@diffkit/file-tree` fork, `@mdxeditor` facts notebook, the Pierre Trees CSS hacks — are explicitly **do-not-port**. + +The transfer is therefore: **keep production's feature-complete engine, adopt the prototype's design system and UX shape.** See `transfer-map.md`. + +--- + +*Synthesized from a 5-agent parallel read-only exploration of `/Users/ramos/oss/diffkit` (`packages/ui`, `packages/icons`, `apps/goal-prototype`) plus `HANDOFF.md` / `DASHBOARD-HANDOFF.md`, 2026-05-30.* diff --git a/goals/ui2point0/settings-consolidation-plan.md b/goals/ui2point0/settings-consolidation-plan.md new file mode 100644 index 000000000..185f916d7 --- /dev/null +++ b/goals/ui2point0/settings-consolidation-plan.md @@ -0,0 +1,73 @@ +# Settings Consolidation — Migration Plan (vetted) + +> Goal: ONE global, config-store-backed settings dialog (`AppSettingsDialog`). Delete the legacy +> monolith `packages/ui/components/Settings.tsx`. Embedded apps (plan + code-review) open the global +> dialog only — no app-local settings dialogs. One source of truth per setting. +> +> Produced + adversarially reviewed by the `settings-consolidation-plan` workflow (6 agents). 2026-05-30. + +--- + +## The reassuring news + +Most of this is **already done**. The map corrected our assumptions: +- `Settings.tsx` is ~1535 lines, already partly gutted. Diff/comments/labels/width/tater are **already on the config store**. +- The embedded apps **already route to the global dialog** (`onOpenSettings` → `appStore.setSettingsOpen`). The monolith's `` mounts in the apps are **dead when embedded** (gated by `skipBuiltInSettings`/`externalOpenSettings`). +- The monolith stays alive for only **4 import sites** + one standalone consumer: + 1. `AppSettingsDialog` imports 3 tabs from it (`GitTab`, `ReviewDisplayTab`, `CommentsTab`). + 2. `DiffOptionsPopover` imports 5 option arrays from it. + 3. code-review `App.tsx` (dead-when-embedded mount). + 4. plan `AppHeader.tsx` (dead-when-embedded mount). + 5. **The portal** (`apps/portal`, share.plannotator.ai) — renders the standalone plan `` with **no `onOpenSettings`**, so the monolith is its **only** settings UI. ← the blocker nobody mapped. + +## The two things the adversarial pass caught + +- **🔴 Identity re-tag regression (the plan had called it "vestigial" — it's not).** `onIdentityChange` re-authors existing annotations (remaps `old→new` author) in both apps (code-review `App.tsx:1090`, plan `App.tsx:1340`). It reaches the user **only** through the monolith. The global dialog's `GeneralTab` doesn't pass `onIdentityChange`. Deleting the monolith silently breaks identity re-tagging in an open session. **Must add a unit to preserve it** (event/store subscription). +- **🟠 code-review's `aiProviders` is NOT settings-only.** It also drives the live in-page AI chat + sidebar (`App.tsx:547,2397`). Removal must **keep** the fetch+state; remove only the `` prop. + +--- + +## Units (critical path: U1 → U2/3/4 → U5 → U9 → U8 → U10) + +| # | Unit | Risk | +|---|---|---| +| U1 | Extract the diff-option constant arrays → `settings/diffOptions.ts`; repoint `DiffOptionsPopover` | low | +| U2 | Extract `GitTab` → `settings/ReviewGitTab.tsx` (config-store-only) | low | +| U3 | Extract `ReviewDisplayTab` → `settings/ReviewDisplayTab.tsx` (mind SegmentedControl/Toggle styling) | low-med | +| U4 | Extract `CommentsTab` → `settings/CommentsTab.tsx` | low | +| U5 | Repoint `AppSettingsDialog` to the new tabs → **dialog is now monolith-free** | low | +| U6 | Make the global dialog **mode-aware** (default tab by active session mode) — must actually call `setActiveTab` in the open effect | low | +| **U+** | **NEW (from review): preserve identity re-tag** — global dialog reaches the active session's store to remap annotation authors on identity change | med | +| U7 | code-review: delete the dead `` mount + import; **keep `aiProviders` fetch/state** (it drives AI chat) | med | +| U8 | plan: delete the built-in `` + `skipBuiltInSettings`/`mobileSettingsOpen` plumbing (keep `taterMode` state). **Gated on U9.** | med-high | +| U9 | **Portal settings path** (the hard precondition) — give `apps/portal` an alternative to the monolith, or drop portal settings | high | +| U10 | **DELETE `Settings.tsx`** — gate: grep shows zero importers + portal decoupled; full typecheck + build:hook + build:portal | med | +| U11 | Migrate `uiPreferences` toc/sticky → config store (closes the TOC/sticky live-update seam from the global dialog; also repoint `App.tsx:256` + `:204`) | med | +| U12 | Make config server-sync **deterministic** — route to `/daemon/config`, not the active session's narrower `/api/config` allowlist (silent-drop bug). **Hard gate before U13.** | med | +| U13 | *(optional, deferred)* migrate trivial stray settings (autoClose/permissionMode/theme…) into the config store. **Keep `agentSwitch` + `aiProvider` specialized.** `quickLabels` has wide blast radius. | med | + +## Adversarial fixes folded in (must-do) +1. Add the **identity re-tag** unit (U+). 2. U7 **keeps** the aiProviders fetch. 3. U6 must actually `setActiveTab(default)` on open + read `bootstrap.session.mode`. 4. Tighten the dialog's `aiProviders` type to include `models?`. 5. U11 also repoints `App.tsx:256` (`useSidebar(getUIPreferences().tocEnabled)`) + the `lastAppliedTocEnabledRef`. 6. U12 is a **hard gate** before any new server-keyed setting. + +## Decisions — LOCKED (2026-05-30) + +1. **ONE universal dialog, no separate portal dialog.** `AppSettingsDialog` becomes *the* settings UI + everywhere (frontend, portal, standalone). → **U9 is replaced**: move the dialog to a shared home + (`packages/ui`) and make it **degrade gracefully without a daemon** — hide the daemon-only controls + (AI providers, Hooks, git name, legacy-tab-mode) when no session/daemon is present; everything else + works cookie-only. The portal renders this same dialog. +2. **The config store routes itself** (U12, elevated to core): server-keyed settings sync to the daemon + when connected, cookie-only when not — one deterministic, daemon-aware pattern. No window-global + ambiguity. This is the mechanism that makes #1 work. +3. **FULL migration** (U13 in scope): every setting moves to the config store as the source of truth; + delete all the stray cookie util read/write surfaces. Keep `agentSwitch` + `aiProvider` as their own + *clean* specialized modules (they're single-source already, not legacy) behind the store where it + helps. `quickLabels` wide blast radius handled carefully (move all `getQuickLabels()` callers). +4. **Section UX:** default to the active session's relevant tab; keep all sections visible. + +Execution: **sequential, verified phases** (not a parallel swarm — units chain + share `Settings.tsx`). +Verify each: typecheck + `build:hook` + `build:portal`. + +--- + +*Full map + plan + review: workflow `w3xbd0psd`. 2026-05-30.* diff --git a/goals/ui2point0/transfer-map.md b/goals/ui2point0/transfer-map.md new file mode 100644 index 000000000..44cf71676 --- /dev/null +++ b/goals/ui2point0/transfer-map.md @@ -0,0 +1,150 @@ +# Transfer Map — Production (legacy) → Prototype (target) + +> The authoritative diff between **where production is** (`legacy-design-state.md`) and **where the +> prototype says we must land** (`prototype-design-state.md`), plus the migration strategy. +> +> **Framing:** the prototype is the *design + UX* target, not a feature target. Production is the +> *feature-complete engine*. The transfer = **keep production's engine, adopt the prototype's +> design system, primitives, shell UX, and surfaces.** Anything the prototype simplifies or omits +> that production already does richly (markdown, themes, daemon runtime, project resolution) is a +> capability we **preserve**, reskinned — not a regression we accept. + +--- + +## 0. The shape of the diff + +Three buckets: + +1. **Adopt wholesale** (prototype is strictly better / net-new): the `@diffkit/ui` primitive set, surface-layer tokens + `--card-shadow`, Shiki, the offcanvas shell + command palette, the code-review tab model, the plan-editor sidebar/grid UX, the dashboard + PR-detail surfaces. +2. **Reconcile** (both have a strong but *different* model — needs a decision): dark-mode polarity, the 50-theme system, the session navigation model (project→worktree tree vs type-grouped offcanvas), the markdown pipeline, syntax highlighting swap, the code-review layout engine. +3. **Preserve & reskin** (production wins on capability; prototype only restyles): goal-setup surfaces, rich markdown features, daemon/WebSocket runtime, project resolution, plan-diff engine, keyboard registry, AI streaming, git-add, the single-file build. + +--- + +## 1. Token & theme system + +| Aspect | Production (legacy) | Prototype (target) | Action | +|---|---|---|---| +| Color space | OKLch (mostly) | OKLch | ✅ aligned | +| **Dark-mode polarity** | **Dark-default**, `.light` flips; `@custom-variant dark (&:not(.light *))` | **Light-default `:root`**, `.dark` class; `@custom-variant dark (&:is(.dark *))` | ⚠️ **RECONCILE** — opposite conventions. Every one of production's ~50 theme files is written dark-first. Flipping polarity is invasive. **Decision needed** (see §8). | +| Themes | **~50** (`packages/ui/themes/*.css`), dark + `.light` each | **1** (diffkit light+dark) | ⚠️ **RECONCILE** — keep multi-theme but re-tokenize, or collapse to diffkit-only? | +| Elevation | ad-hoc `--surface-0/1/2` (via color-mix) + `--card-shadow` + `bg-muted/30\|50` used inconsistently | **first-class `--surface-0/1/2`** + `--card-shadow`/`--card-ring`, consistent `bg-surface-1`/`/30`/`/50` + hover `surface-2` | ✅ **ADOPT** the prototype's disciplined surface convention; bake into every theme | +| Radius | `--radius 0.625` + sm/md/lg/xl | same | ✅ aligned | +| Tokens prod lacks | — | `--chart-1..5`, `--brand`/`--brand-dev`, `--alert-color` per kind, full sidebar token set | ✅ **ADD** to the token contract | +| Fonts | Inter + Geist Mono (pinned 5.2.7) + **Instrument Sans** | Inter + Geist Mono | keep production's set; Instrument Sans is extra, harmless | + +**Net:** the token *contract* the rewrite targets = production's OKLch base **+ prototype's surface layers, card-shadow, chart/brand/alert tokens**. The two unresolved questions are **polarity** and **theme count**. + +--- + +## 2. Primitive layer — the biggest structural win + +| | Production | Prototype | Action | +|---|---|---|---| +| Shared primitives | **split brain**: partial shadcn in `apps/frontend/src/components/ui/` (~7: button/dialog/input/tabs/tooltip/sheet/sidebar) **+** older `packages/ui/components/*` (100+ files) using **hardcoded Tailwind strings**, no shared Button/Input | **one** `@diffkit/ui` package, **28 cva primitives**, new-york style | ✅ **ADOPT** `@diffkit/ui` as *the* shared primitive library; migrate `packages/ui/components/*` onto it | +| Button | basic cva (6 variants × xxs/xs/sm/default/lg/icon) | same + **`iconLeft`/`iconRight` slots** | adopt prototype's | +| Net-new primitives | — | **state-pill, command (palette), breadcrumb, avatar, callout, markdown-editor, logo** | ✅ bring across | +| Tabs/Tooltip | token-styled | **surface-layer aware** (`bg-surface-1 p-px`, active `bg-surface-0 shadow-sm`) | adopt | +| Dialog | Radix only | **responsive** Radix↔vaul-drawer | adopt (vaul already a prod dep) | + +**This resolves the #1 tension in `legacy-design-state.md`** ("two styling conventions coexist"). The migration: stand up `@diffkit/ui` (or its equivalent inside `packages/ui`), then sweep `packages/ui/components/*` to consume primitives instead of raw utility strings. + +--- + +## 3. Icons + +| Production | Prototype | Action | +|---|---|---| +| Hand-rolled inline SVG brand set (`themeIcons`, `ProviderIcons`, `GitHubIcon`, …); `lucide-react` installed but **barely used**; no unified sizing | **`lucide-react` used directly everywhere** (`size={N}`) + small `@diffkit/icons` (hugeicons wrapper + 6 custom + brand logos) | ✅ **STANDARDIZE on lucide** as the working set with consistent `size`; keep a small custom set only for brand/provider glyphs lucide lacks (Claude/Codex/Pi/OpenCode, GitHub/GitLab). Resolves the "scattered hand-rolled icons" tension. | + +--- + +## 4. Syntax highlighting & markdown + +| | Production | Prototype | Action | +|---|---|---|---| +| Code highlight | **highlight.js** + `github-dark.css`, per-block `highlightElement` | **Shiki 4.0.2**, 27-lang bundle, dual-theme `diffkit-light/dark`, cached | ⚠️ **RECONCILE → adopt Shiki.** Premium, theme-token-aware, but heavier bundle + async. Touches every code block + the diff theming. | +| Diff theming | `@pierre/diffs` already in prod deps | Shiki `quickhubLight/Dark` injected into Pierre shadow DOM via `unsafeCSS` + MutationObserver | adopt the Pierre theme-sync approach | +| Markdown richness | **custom `parser.ts` → BlockRenderer + `InlineMarkdown` (20+ patterns)**: code-file links w/ validation gate, wiki-links, hex swatches, `#issue`/`@mention`, mermaid, graphviz, image zoom, external-annotation SSE | `@diffkit/ui/markdown.tsx` (remark-gfm + github-alerts + rehype-raw + Shiki, 32 overrides) **OR** the plan editor's own simpler inline parser | ⚠️ **PRESERVE production's richer pipeline** — the prototype markdown is *simpler*, do not regress mermaid/graphviz/wiki-links/code-file gate. Reskin production's renderers with Shiki + diffkit tokens; optionally use `@diffkit/ui/markdown` as the base and **port production's extra inline patterns onto it**. | +| Comment editor | — (annotation inputs are plain textareas) | **`markdown-editor.tsx`**: write/preview, @mention autocomplete, toolbar, media drop | ✅ net-new capability — adopt for annotation/PR-comment inputs | + +**Markdown is the one place production > prototype in capability.** Treat the prototype as the *visual* target and production's parser as the *feature floor*. + +--- + +## 5. Layout engines + +| Surface | Production | Prototype | Action | +|---|---|---|---| +| **Code review** | **dockview-react** (IDE panels) | **simple state tab bar** (Dockview *explicitly abandoned*) | ⚠️ **RECONCILE → replace Dockview with the tab model.** Large change: `packages/plannotator-code-review/dock/` + `ReviewStateContext` (the context that feeds static dockview panels) go away; panels become tab content. Removes a whole dependency + the "panels can't take React props" workaround. | +| **Plan review sidebar** | custom resizable panels | **drag-to-close (60% snap) + hover gutter + `[data-sidebar-panel]` CSS** | ✅ **ADOPT** the prototype's gutter UX verbatim | +| **Plan grid view** | none | **grid-pattern + document-card toggle** | ✅ net-new — adopt (mind the `bg-grid`→`grid-pattern` twMerge gotcha) | + +--- + +## 6. App shell & session navigation — the hardest reconcile + +| | Production (post-#822) | Prototype | Tension | +|---|---|---|---| +| Routing | **TanStack Router** (`__root`/`index`/`s.$sessionId`), keep-alive `SessionSurface` per visited session | **no router**, state-based view switch | Production's router + keep-alive perf pattern is real infrastructure; prototype's no-router is a prototype shortcut. **Keep the router**, adopt the prototype's *visual* shell on top. | +| Session grouping | **project → worktree → session** tree (`AppSidebar`, from #822 project resolution) | **flat, grouped by session TYPE** (Plan/Code/Goal/Facts) | ⚠️ **RECONCILE.** Production's project/worktree model is richer and load-bearing (history keyed on it). The prototype's type-grouping is simpler/flatter. Likely: **keep project→worktree as the primary axis, layer type/status as secondary** — don't lose project resolution. | +| Sidebar mode | 244px desktop sidebar + `SidebarPeek` hover-reveal when collapsed | **offcanvas** (hidden by default, slides over) | ⚠️ **RECONCILE.** Prototype's offcanvas rationale (browser vertical tabs → no double sidebar) is sound. Decision: switch to offcanvas, or keep the peek-rail? | +| Command palette | none (only a KeyboardShortcuts modal) | **cmdk Cmd+K palette** (sessions + actions + headings) | ✅ net-new — adopt | +| Landing | 3-pane translateX carousel (project selector + ConjoinedSessionsHistory / GitDashboard / FullHistory) | **Dashboard** + **PR Detail** pages | reconcile: prototype's Dashboard/PRDetail are more GitHub-grade; fold production's git-dashboard data into them | + +**This is where the most design discussion is needed** — the prototype flattened away the project→worktree model that #822 just built. The reconcile is "prototype UX shell, production data model." + +--- + +## 7. Preserve & reskin (production wins, prototype only restyles) + +These need **no architectural change** — just the new tokens/primitives/icons applied: + +- **Goal setup** (Interview + Facts) — handoff confirms production's `GoalSetupSurface` is *more* complete than the prototype. Reference-only; reskin. +- **Plan-diff** word-level engine, **version history**, **annotation/web-highlighter**, **image annotator**, **external-annotations SSE**, **AI session streaming**, **git-add staging**, **PR switch/stack**, **code tour** (production already has a richer one) — keep, reskin. +- **Daemon / WebSocket runtime / session persistence / project resolution** — untouched by UI 2.0; the design layer sits above it. +- **Single-file embedded-in-daemon build** — keep; the prototype's plain-Vite setup is dev-only. + +--- + +## 8. Open decisions (need your call before/within the rewrite) + +> **RESOLVED 2026-05-30 — see `decisions.md`.** Locked: keep dark-first (#1), keep all ~50 +> themes (#2), keep our project→worktree sidebar + no Cmd+K (#3), keep highlight.js (#4), +> keep our production-grade markdown (#6), rebuild primitives in `packages/ui` not vendored +> (#7). Code-review engine (#5) deferred. The original list is retained below for context. + +1. **Dark-mode polarity** — flip production to light-default `:root` + `.dark` (match prototype, invasive across ~50 themes), or keep dark-default and adapt the prototype's tokens to it? +2. **Theme count** — keep the ~50-theme system (re-tokenized with surface layers etc.), or collapse to the single diffkit theme as the prototype implies? +3. **Session navigation** — offcanvas (prototype) vs the project→worktree tree + peek-rail (production #822). And how to merge type/status grouping with project/worktree grouping. +4. **Highlighting swap** — commit to Shiki (premium, heavier/async) vs keep highlight.js? (Prototype strongly implies Shiki.) +5. **Code-review engine** — confirm replacing Dockview with the tab model (removes `dock/` + `ReviewStateContext`). +6. **Markdown base** — extend `@diffkit/ui/markdown` with production's inline patterns, or keep production's `parser.ts` and only reskin it? +7. **Package boundary** — adopt `@diffkit/ui` as a vendored package, or rebuild its primitives inside `packages/ui`? (Plannotator's build embeds into the daemon binary — a new workspace dep is fine but must survive single-file bundling.) + +--- + +## 9. Do-not-port (explicit from the handoff) + +- **`@diffkit/file-tree`** — 22K-line Pierre Trees fork; CSS indentation overrides never worked (shadow DOM). Build a simpler tree or use Pierre as-is. **Port only the UI patterns** (DiffTypePicker, BaseBranchPicker, viewed circles, +N/−M decorations). +- **`@mdxeditor/editor` facts notebook** (`FactsNotebook.tsx`) — abandoned; card-based FactsReview is the right model. +- The Pierre Trees CSS-variable override hacks. + +--- + +## 10. Suggested migration order (low-risk → high-risk) + +1. **Token foundation** — land the surface-layer + card-shadow + chart/brand/alert token contract; resolve polarity & theme-count decisions (§8.1, §8.2). *Everything else depends on this.* +2. **Primitive library** — stand up the `@diffkit/ui` primitive set in the shared package; resolve the package-boundary decision (§8.7). +3. **Icon standardization** — lucide + small brand set, consistent sizing. +4. **Reskin the stable surfaces** — goal setup, plan viewer, annotation panels — onto new tokens/primitives (no behavior change). Validates the system end-to-end. +5. **Shell** — offcanvas + command palette + Dashboard/PR-detail, reconciled with the router + project→worktree model (§6). +6. **Plan-editor UX** — sidebar drag-to-close + grid view. +7. **Code-review engine swap** — Dockview → tab model (§5). Highest risk; do last. +8. **Highlighting swap** — highlight.js → Shiki + Pierre theme sync (can run parallel to 4–7). + +Each step is independently shippable and reversible; nothing after step 1 blocks daemon/runtime work. + +--- + +*Companion to `legacy-design-state.md` (current) and `prototype-design-state.md` (target). 2026-05-30.* diff --git a/goals/worktree-projects/facts.md b/goals/worktree-projects/facts.md new file mode 100644 index 000000000..b60c54633 --- /dev/null +++ b/goals/worktree-projects/facts.md @@ -0,0 +1,47 @@ +# Worktree-Aware Project Hierarchy — Facts + +## Auto-Detection + +- When a user adds a directory that is a git worktree, the daemon auto-detects the parent repo using `git rev-parse --git-common-dir`. +- The parent repo becomes a top-level project entry if it doesn't already exist. +- The added worktree directory nests under the parent project automatically. +- Adding a regular repo (not a worktree) works the same as today — it becomes a top-level project. + +## Data Model + +- `DaemonProjectEntry` gains an optional `parentCwd` field. Worktree entries have `parentCwd` set to the parent repo's cwd. Regular projects leave it unset. +- The on-disk format (`~/.plannotator/projects.json`) stays a flat array. The tree structure is resolved at query time, not stored. +- A new optional `branch` field on `DaemonProjectEntry` stores the worktree's checked-out branch name for display. + +## Worktree Listing + +- Expanding a project node in the UI triggers a `git worktree list` call via a daemon API endpoint. +- Worktree data is fetched on demand, not cached or polled. Each expand gets fresh data. +- The daemon returns worktrees as an array of `{ path, branch, head }` using the existing `WorktreeInfo` type from `packages/shared/review-core.ts`. + +## Landing Page UI + +- The project table on the landing page shows projects as collapsible tree nodes. +- Projects with worktrees display a chevron/expand control. +- Clicking the chevron expands the node and shows worktrees indented underneath, each with its branch name and path. +- Both parent projects and worktree entries are selectable for launching sessions (Code Review, Browse Archive). +- Selecting a worktree entry passes its `cwd` (the worktree path) to the session creation API. +- Projects without worktrees display the same as today — a flat row with no expand control. +- Collapsed by default. + +## Sidebar + +- The sidebar continues to show only sessions, not projects. No change to sidebar project display. +- Sessions created from a worktree cwd show the branch name in their sidebar label for context. + +## Actions + +- All session actions (Code Review, Browse Archive, Plan, Annotate) work identically on both parent projects and worktree entries. +- The only difference is the `cwd` passed to the daemon — the parent repo path or the worktree path. + +## Out of Scope + +- Worktree creation or deletion from the UI. Users manage worktrees via git CLI. +- Sidebar project hierarchy. Projects stay landing-page-only. +- Automatic worktree scanning in the background or on a timer. +- Worktree-specific session grouping in the sidebar (sessions group by mode, not by worktree). diff --git a/goals/worktree-projects/goal.md b/goals/worktree-projects/goal.md new file mode 100644 index 000000000..6d0153c08 --- /dev/null +++ b/goals/worktree-projects/goal.md @@ -0,0 +1,19 @@ +# Worktree-Aware Project Hierarchy + +Make the project list understand git worktree relationships. Directories that are worktrees auto-detect their parent repo and nest underneath it. Projects with worktrees show them as expandable branches. Users can launch sessions scoped to any worktree. + +## Shared Understanding + +See `facts.md` for the approved fact sheet. + +## Execution Plan + +See `plan.md`. + +## Done Condition + +- Adding a worktree directory auto-detects parent and creates hierarchy +- Expanding a project shows its worktrees with branch names +- Sessions can be launched from any worktree entry +- Session sidebar labels include branch name for worktree sessions +- Typecheck and tests pass diff --git a/goals/worktree-projects/plan.md b/goals/worktree-projects/plan.md new file mode 100644 index 000000000..e3380bc0a --- /dev/null +++ b/goals/worktree-projects/plan.md @@ -0,0 +1,22 @@ +# Worktree-Aware Project Hierarchy — Plan + +## Approach + +Extend the project registry data model with `parentCwd` and `branch` fields. When a directory is added, detect if it's a worktree and auto-discover the parent repo. Add a daemon endpoint to list worktrees for a project. Update the landing page to render projects as collapsible tree nodes with worktrees nested underneath. Add branch names to session labels for worktree-scoped sessions. + +## Steps + +1. Extend `DaemonProjectEntry` type with optional `parentCwd` and `branch` +2. Add worktree detection to `registerProject` / `addProject` +3. Add `GET /daemon/projects/worktrees?cwd=` endpoint +4. Add `listWorktrees` to frontend API client +5. Refactor `ProjectTable` to collapsible tree with worktree children +6. Add branch name to session labels for worktree cwds + +## Verification + +- Add a worktree directory → parent auto-detected, nests correctly +- Expand project → worktrees listed with branch names +- Select worktree → launch code review scoped to that path +- Session label shows branch name +- Typecheck + tests pass diff --git a/package.json b/package.json index d8173526e..a7e09647c 100644 --- a/package.json +++ b/package.json @@ -18,22 +18,22 @@ }, "workspaces": ["apps/*", "packages/*"], "scripts": { - "dev:hook": "bun run --cwd apps/hook dev", "dev:portal": "bun run --cwd apps/portal dev", - "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:marketing": "bun run --cwd apps/marketing dev", + "dev:frontend": "bun run scripts/dev-frontend.ts", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", "build:opencode": "bun run --cwd apps/opencode-plugin build", - "build:review": "bun run --cwd apps/review build", - "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", + "build:pi": "bun run build:hook && bun run --cwd apps/pi-extension build", "build": "bun run build:hook && bun run build:opencode", "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", - "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "test": "bash apps/pi-extension/vendor.sh && bun test", + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json && tsc --noEmit -p apps/amp-plugin/tsconfig.json", + "build:amp": "bun run --cwd apps/amp-plugin build" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", @@ -47,8 +47,10 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@types/dompurify": "^3.2.0", "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11" + "bun-types": "^1.3.11", + "typescript": "~5.8.2" } } diff --git a/packages/ai/providers/claude-agent-sdk.ts b/packages/ai/providers/claude-agent-sdk.ts index 05f69ee89..8b4724fd8 100644 --- a/packages/ai/providers/claude-agent-sdk.ts +++ b/packages/ai/providers/claude-agent-sdk.ts @@ -69,6 +69,8 @@ export class ClaudeAgentSDKProvider implements AIProvider { tools: true, }; readonly models = [ + { id: 'claude-opus-4-8', label: 'Opus 4.8' }, + { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)' }, { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', default: true }, { id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)' }, { id: 'claude-opus-4-7', label: 'Opus 4.7' }, diff --git a/packages/plannotator-code-review/App.d.ts b/packages/plannotator-code-review/App.d.ts new file mode 100644 index 000000000..a3d9a93a1 --- /dev/null +++ b/packages/plannotator-code-review/App.d.ts @@ -0,0 +1,4 @@ +import type { FC, ReactNode } from "react"; +export declare const ReviewAppEmbedded: FC<{ headerLeft?: ReactNode }>; +declare const ReviewApp: FC; +export default ReviewApp; diff --git a/packages/review-editor/App.tsx b/packages/plannotator-code-review/App.tsx similarity index 85% rename from packages/review-editor/App.tsx rename to packages/plannotator-code-review/App.tsx index 03df66ee1..133f72d99 100644 --- a/packages/review-editor/App.tsx +++ b/packages/plannotator-code-review/App.tsx @@ -3,12 +3,12 @@ import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider'; import { TooltipProvider } from '@plannotator/ui/components/Tooltip'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; -import { Settings } from '@plannotator/ui/components/Settings'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; import { AgentReviewActions } from './components/AgentReviewActions'; import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { CompletionBanner } from '@plannotator/ui/components/CompletionBanner'; import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon'; import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; import { RepoIcon } from '@plannotator/ui/components/RepoIcon'; @@ -38,6 +38,7 @@ import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs'; +import { subscribeToDaemonSessionFamily } from '@plannotator/ui/utils/daemonHub'; import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react'; @@ -46,6 +47,7 @@ import { ReviewSidebar } from './components/ReviewSidebar'; import type { ReviewSidebarTab } from './components/ReviewSidebar'; import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon'; import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; +import { FolderTree } from 'lucide-react'; import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; import { FileTree } from './components/FileTree'; import { StackedPRLabel } from './components/StackedPRLabel'; @@ -75,12 +77,16 @@ import { REVIEW_CODE_NAV_PANEL_ID, } from './dock/reviewPanelTypes'; import type { DiffFile } from './types'; +import { retainUnchangedViewedFiles } from './utils/diffFiles'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; import type { PRMetadata } from '@plannotator/shared/pr-types'; import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; import { altKey } from '@plannotator/ui/utils/platform'; import { TourDialog } from './components/tour/TourDialog'; import { DEMO_TOUR_ID } from './demoTour'; +import { useSessionFetch } from '@plannotator/ui/hooks/useSessionFetch'; +import { ReviewStoreProvider, useReviewStore, useReviewStoreApi } from './store'; +import { selectAllAnnotations } from './store/selectors'; declare const __APP_VERSION__: string; @@ -134,20 +140,43 @@ function getFileTabTitle(filePath: string): string { return filePath.split('/').pop() ?? filePath; } -const ReviewApp: React.FC = () => { +function useSessionVisible(rootRef: React.RefObject): boolean { + const [visible, setVisible] = useState(true); + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const container = el.parentElement; + if (!container) return; + const check = () => setVisible(getComputedStyle(el).visibility !== 'hidden'); + check(); + const observer = new MutationObserver(check); + observer.observe(container, { attributes: true, attributeFilter: ['style'] }); + return () => observer.disconnect(); + }, []); + return visible; +} + +const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpenSettings?: () => void }> = ({ __embedded, headerLeft, onOpenSettings: externalOpenSettings }) => { + const fetch = useSessionFetch(); const { resolvedMode } = useTheme(); + const rootRef = useRef(null); + const sessionVisible = useSessionVisible(rootRef); + const isVisible = useCallback(() => { + if (!rootRef.current) return true; + return getComputedStyle(rootRef.current).visibility !== 'hidden'; + }, []); + const storeApi = useReviewStoreApi(); + const localAnnotations = useReviewStore(s => s.localAnnotations); + const selectedAnnotationId = useReviewStore(s => s.selectedAnnotationId); + const pendingSelection = useReviewStore(s => s.pendingSelection); + const files = useReviewStore(s => s.files); + const activeFileIndex = useReviewStore(s => s.focusedFileIndex); const [diffData, setDiffData] = useState(null); - const [files, setFiles] = useState([]); - const [activeFileIndex, setActiveFileIndex] = useState(0); - const [annotations, setAnnotations] = useState([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); - const [isAllFilesActive, setIsAllFilesActive] = useState(false); + const isAllFilesActive = useReviewStore(s => s.isAllFilesActive); const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null); - const [pendingSelection, setPendingSelection] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [showWorktreeDialog, setShowWorktreeDialog] = useState(false); - const [openSettingsMenu, setOpenSettingsMenu] = useState(false); const [showNoAnnotationsDialog, setShowNoAnnotationsDialog] = useState(false); const [isLoading, setIsLoading] = useState(true); const diffStyle = useConfigValue('diffStyle'); @@ -159,23 +188,29 @@ const ReviewApp: React.FC = () => { const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); - const diffTabSize = useConfigValue('diffTabSize'); - // Load custom diff font and override --font-mono for surrounding review elements useEffect(() => { + const el = rootRef.current; + if (!el) return; if (diffFontFamily) { loadDiffFont(diffFontFamily); - document.documentElement.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); + el.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); } else { - document.documentElement.style.removeProperty('--diff-font-override'); + el.style.removeProperty('--diff-font-override'); } if (diffFontSize) { - document.documentElement.style.setProperty('--diff-font-size-override', diffFontSize); + el.style.setProperty('--diff-font-size-override', diffFontSize); + el.classList.add('has-font-size-override'); } else { - document.documentElement.style.removeProperty('--diff-font-size-override'); + el.style.removeProperty('--diff-font-size-override'); + el.classList.remove('has-font-size-override'); } - document.documentElement.style.setProperty('--diffs-tab-size', String(diffTabSize)); - }, [diffFontFamily, diffFontSize, diffTabSize]); + return () => { + el.style.removeProperty('--diff-font-override'); + el.style.removeProperty('--diff-font-size-override'); + el.classList.remove('has-font-size-override'); + }; + }, [diffFontFamily, diffFontSize]); const reviewSidebar = useSidebar(true, 'annotations'); const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); @@ -184,8 +219,8 @@ const ReviewApp: React.FC = () => { const [viewedFiles, setViewedFiles] = useState>(new Set()); const [hideViewedFiles, setHideViewedFiles] = useState(false); const [origin, setOrigin] = useState(null); - const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); + const [legacyTabMode, setLegacyTabMode] = useState(false); const [diffType, setDiffType] = useState('uncommitted'); const [gitContext, setGitContext] = useState(null); // Two bases: @@ -206,14 +241,16 @@ const ReviewApp: React.FC = () => { const [isApproving, setIsApproving] = useState(false); const [isExiting, setIsExiting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false); + const [feedbackSent, setFeedbackSent] = useState(false); const [showApproveWarning, setShowApproveWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); useEffect(() => { + if (!sessionVisible) return; document.title = repoInfo ? `${repoInfo.display} · Code Review` : "Code Review"; - }, [repoInfo]); + }, [repoInfo, sessionVisible]); const { prMetadata, prStackInfo, prStackTree, prDiffScope, prDiffScopeOptions, updatePRSession } = usePRSession(); const { withPRContext } = useAnnotationFactory(prMetadata, prStackInfo ? prDiffScope : undefined); @@ -261,21 +298,51 @@ const ReviewApp: React.FC = () => { const identity = useConfigValue('displayName'); const clearPendingSelection = useCallback(() => { - setPendingSelection(null); - }, []); + storeApi.getState().setPendingSelection(null); + }, [storeApi]); // VS Code editor annotations (only polls when inside VS Code webview) const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); - // External annotations (SSE-based, for any external tool) + // External annotations (HTTP mutations + daemon WebSocket events) // TODO: Replace !!origin with a dedicated isApiMode boolean (set on /api/diff success/failure). - // origin is an identity field, not a connectivity signal — the standalone dev server - // (apps/review/) doesn't set it, so external annotations are silently disabled there. + // origin is an identity field, not a connectivity signal — the demo/standalone path + // doesn't set it, so external annotations are silently disabled there. // The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI) // so this should be addressed as a broader refactor. const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: !!origin }); const agentJobs = useAgentJobs({ enabled: !!origin }); + // Listen for session-revision events (agent pushed a new diff) + useEffect(() => { + if (!origin) return; + const unsubscribe = subscribeToDaemonSessionFamily("session-revision", (msg) => { + if (!msg.payload) return; + const revision = msg.payload as { rawPatch?: string; gitRef?: string }; + if (revision.rawPatch !== undefined) { + const oldFiles = storeApi.getState().files; + const newFiles = parseDiffToFiles(revision.rawPatch); + const contentChanged = newFiles.length !== oldFiles.length || + newFiles.some((f, i) => f.patch !== oldFiles[i]?.patch); + if (contentChanged) { + setDiffData(prev => prev ? { ...prev, rawPatch: revision.rawPatch!, gitRef: revision.gitRef ?? prev.gitRef } : prev); + storeApi.getState().setFiles(newFiles); + storeApi.getState().setFocusedFile(0); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + setViewedFiles(prev => retainUnchangedViewedFiles(oldFiles, newFiles, prev)); + } + if (contentChanged || msg.type === "event") { + setFeedbackSent(false); + setSubmitted(false); + setIsSendingFeedback(false); + } + } + }); + return unsubscribe; + }, [origin, storeApi]); + // Tour dialog state — opens as an overlay instead of a dock panel const [tourDialogJobId, setTourDialogJobId] = useState(null); @@ -285,6 +352,15 @@ const ReviewApp: React.FC = () => { filesRef.current = files; const needsInitialDiffPanel = useRef(true); + useEffect(() => { storeApi.getState().setExternalAnnotations(externalAnnotations); }, [storeApi, externalAnnotations]); + useEffect(() => { + storeApi.getState().setDiffOptions({ + diffStyle, diffOverflow, diffIndicators, lineDiffType: diffLineDiffType, + disableLineNumbers: !diffShowLineNumbers, disableBackground: !diffShowBackground, + fontFamily: diffFontFamily || undefined, fontSize: diffFontSize || undefined, + }); + }, [storeApi, diffStyle, diffOverflow, diffIndicators, diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffFontFamily, diffFontSize]); + // PR context (lifted from sidebar so center dock PR panels can access it) const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null); @@ -297,7 +373,7 @@ const ReviewApp: React.FC = () => { if (!dockApi) { const fileIndex = files.findIndex(candidate => candidate.path === filePath); if (fileIndex !== -1) { - setActiveFileIndex(fileIndex); + storeApi.getState().setFocusedFile(fileIndex); } return; } @@ -311,18 +387,18 @@ const ReviewApp: React.FC = () => { } const fileIndex = files.findIndex(candidate => candidate.path === filePath); if (fileIndex !== -1) { - setActiveFileIndex(fileIndex); + storeApi.getState().setFocusedFile(fileIndex); } needsInitialDiffPanel.current = false; return; } - setPendingSelection(null); + storeApi.getState().setPendingSelection(null); existing.api.updateParameters({ filePath }); existing.api.setTitle(getFileTabTitle(file.path)); existing.api.setActive(); } else { - setPendingSelection(null); + storeApi.getState().setPendingSelection(null); dockApi.addPanel({ id: REVIEW_DIFF_PANEL_ID, component: REVIEW_PANEL_TYPES.DIFF, @@ -331,7 +407,7 @@ const ReviewApp: React.FC = () => { }); } - setActiveFileIndex(files.findIndex(candidate => candidate.path === filePath)); + storeApi.getState().setFocusedFile(files.findIndex(candidate => candidate.path === filePath)); needsInitialDiffPanel.current = false; }, [dockApi, files]); @@ -368,44 +444,27 @@ const ReviewApp: React.FC = () => { !!gitContext?.diffOptions?.length || !!gitContext?.worktrees?.length; - // Merge local + SSE annotations, deduping draft-restored externals against - // live SSE versions. Prefer the SSE version when both exist (same source, + // Merge local + live annotations, deduping draft-restored externals against + // live WebSocket versions. Prefer the live version when both exist (same source, // type, and originalText). This avoids the timing issues of an effect-based - // cleanup — draft-restored externals persist until SSE actually re-delivers them. - const allAnnotations = useMemo(() => { - if (externalAnnotations.length === 0) return annotations; - - const local = annotations.filter(a => { - if (!a.source) return true; - return !externalAnnotations.some(ext => - ext.source === a.source && - ext.type === a.type && - ext.filePath === a.filePath && - ext.lineStart === a.lineStart && - ext.lineEnd === a.lineEnd && - ext.side === a.side - ); - }); - - return [...local, ...externalAnnotations]; - }, [annotations, externalAnnotations]); - const allAnnotationsRef = useRef(allAnnotations); - allAnnotationsRef.current = allAnnotations; - - // Auto-save code annotation drafts - const { draftBanner, restoreDraft, dismissDraft } = useCodeAnnotationDraft({ + // cleanup — draft-restored externals persist until live events re-deliver them. + const allAnnotations = useMemo( + () => selectAllAnnotations({ localAnnotations, externalAnnotations }), + [localAnnotations, externalAnnotations], + ); + // Auto-save and auto-restore code annotation drafts + useCodeAnnotationDraft({ annotations: allAnnotations, viewedFiles, isApiMode: !!origin, submitted: !!submitted, + onRestore: useCallback((restoredAnnotations: CodeAnnotation[], restoredViewed: string[]) => { + if (restoredAnnotations.length > 0) storeApi.getState().setLocalAnnotations(restoredAnnotations); + if (restoredViewed.length > 0) setViewedFiles(new Set(restoredViewed)); + toast(`Restored ${restoredAnnotations.length} annotation${restoredAnnotations.length !== 1 ? 's' : ''}${restoredViewed.length > 0 ? ` and ${restoredViewed.length} viewed file${restoredViewed.length !== 1 ? 's' : ''}` : ''}`); + }, [storeApi]), }); - const handleRestoreDraft = useCallback(() => { - const restored = restoreDraft(); - if (restored.annotations.length > 0) setAnnotations(restored.annotations); - if (restored.viewedFiles.length > 0) setViewedFiles(new Set(restored.viewedFiles)); - }, [restoreDraft]); - // AI Chat const [aiAvailable, setAiAvailable] = useState(false); const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); @@ -522,21 +581,22 @@ const ReviewApp: React.FC = () => { }, [aiProviders, origin, resetAISession]); const handleAskAI = useCallback((question: string) => { - if (!pendingSelection || !files[activeFileIndex]) return; - const lineStart = Math.min(pendingSelection.start, pendingSelection.end); - const lineEnd = Math.max(pendingSelection.start, pendingSelection.end); - const side = pendingSelection.side === 'additions' ? 'new' : 'old'; - const selectedCode = extractLinesFromPatch(files[activeFileIndex].patch, lineStart, lineEnd, side); + const { pendingSelection: sel, files: f, focusedFileIndex } = storeApi.getState(); + if (!sel || !f[focusedFileIndex]) return; + const lineStart = Math.min(sel.start, sel.end); + const lineEnd = Math.max(sel.start, sel.end); + const side = sel.side === 'additions' ? 'new' : 'old'; + const selectedCode = extractLinesFromPatch(f[focusedFileIndex].patch, lineStart, lineEnd, side); askAI({ prompt: question, - filePath: files[activeFileIndex].path, + filePath: f[focusedFileIndex].path, lineStart, lineEnd, side, selectedCode: selectedCode || undefined, }); - }, [activeFileIndex, askAI, files, pendingSelection]); + }, [storeApi, aiChat]); const handleViewAIResponse = useCallback((questionId?: string) => { reviewSidebar.open('ai'); @@ -549,12 +609,12 @@ const ReviewApp: React.FC = () => { const handleScrollToAILines = useCallback((filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => { openDiffFile(filePath); // Set a selection to highlight the lines - setPendingSelection({ + storeApi.getState().setPendingSelection({ start: lineStart, end: lineEnd, side: side === 'new' ? 'additions' : 'deletions', }); - }, [openDiffFile]); + }, [storeApi, openDiffFile]); // AI messages overlapping the current selection (for toolbar history) @@ -587,10 +647,20 @@ const ReviewApp: React.FC = () => { }, [askAI]); // Resizable panels - const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' }); + const panelResize = useResizablePanel({ + storageKey: 'plannotator-review-panel-width', + side: 'right', + // Drag the panel skinny → snap it shut (matches the in-plan panels). + onSnapClose: () => reviewSidebar.close(), + // Render-free drag: write the live width to a :root CSS var so the heavy + // review app never re-renders mid-drag; React commits state on release. + apply: (w) => document.documentElement.style.setProperty('--cr-rpanel-w', `${w}px`), + }); const fileTreeResize = useResizablePanel({ storageKey: 'plannotator-filetree-width', defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left', + onSnapClose: () => setIsFileTreeOpen(false), + apply: (w) => document.documentElement.style.setProperty('--cr-filetree-w', `${w}px`), }); const isResizing = panelResize.isDragging || fileTreeResize.isDragging; @@ -601,15 +671,15 @@ const ReviewApp: React.FC = () => { // Sync activeFileIndex when user switches between dock tabs event.api.onDidActivePanelChange((panel) => { - if (!panel) { setIsAllFilesActive(false); setIsDiffPanelActive(false); return; } - setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID); + if (!panel) { storeApi.getState().setIsAllFilesActive(false); setIsDiffPanelActive(false); return; } + storeApi.getState().setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID); setIsDiffPanelActive(isReviewDiffPanelId(panel.id)); if (!isReviewDiffPanelId(panel.id)) return; const filePath = getReviewDiffPanelFilePath(panel.params); if (!filePath) return; const fileIndex = filesRef.current.findIndex(file => file.path === filePath); if (fileIndex !== -1) { - setActiveFileIndex(fileIndex); + storeApi.getState().setFocusedFile(fileIndex); } }); @@ -663,6 +733,7 @@ const ReviewApp: React.FC = () => { useEffect(() => { if (!import.meta.env.DEV) return; const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'T' || e.key === 't')) { e.preventDefault(); setTourDialogJobId(prev => (prev === DEMO_TOUR_ID ? null : DEMO_TOUR_ID)); @@ -729,6 +800,7 @@ const ReviewApp: React.FC = () => { // Global keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; // Cmd/Ctrl+F to focus file search when diff files are available. if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) { if (hasSearchableFiles) { @@ -808,11 +880,10 @@ const ReviewApp: React.FC = () => { error?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string }; + lastDecision?: 'approved' | 'feedback' | 'exited' | null; }) => { - // Initialize config store with server-provided values (config file > cookie > default) - configStore.init(data.serverConfig); - // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable - setGitUser(data.serverConfig?.gitUser); + configStore.getState().init(data.serverConfig); + if ((data.serverConfig as { legacyTabMode?: boolean } | undefined)?.legacyTabMode) setLegacyTabMode(true); const apiFiles = parseDiffToFiles(data.rawPatch); setDiffData({ files: apiFiles, @@ -823,7 +894,7 @@ const ReviewApp: React.FC = () => { gitContext: data.gitContext, sharingEnabled: data.sharingEnabled, }); - setFiles(apiFiles); + storeApi.getState().setFiles(apiFiles); if (data.origin) setOrigin(data.origin); if (data.diffType) setDiffType(data.diffType); if (data.gitContext) { @@ -856,6 +927,11 @@ const ReviewApp: React.FC = () => { if (data.diffType && !data.prMetadata && data.gitContext?.vcsType !== 'p4' && data.gitContext?.vcsType !== 'jj' && needsDiffTypeSetup()) { setDiffTypeSetupPending(true); } + if (data.lastDecision) { + if (data.lastDecision === 'approved') setSubmitted('approved'); + else if (data.lastDecision === 'feedback') setFeedbackSent(true); + else if (data.lastDecision === 'exited') setSubmitted('exited'); + } }) .catch(() => { // Not in API mode - use demo content @@ -865,7 +941,7 @@ const ReviewApp: React.FC = () => { rawPatch: DEMO_DIFF, gitRef: 'demo', }); - setFiles(demoFiles); + storeApi.getState().setFiles(demoFiles); }) .finally(() => setIsLoading(false)); }, []); @@ -879,13 +955,13 @@ const ReviewApp: React.FC = () => { }, [diffTypeSetupPending]); const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => { - configStore.set('diffStyle', style); + configStore.getState().set('diffStyle', style); }, []); // Handle line selection from diff viewer const handleLineSelection = useCallback((range: SelectedLineRange | null) => { - setPendingSelection(range); - }, []); + storeApi.getState().setPendingSelection(range); + }, [storeApi]); const handleAddAnnotationForFile = useCallback(( filePath: string, @@ -897,9 +973,10 @@ const ReviewApp: React.FC = () => { decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta ) => { - if (!pendingSelection) return; - const lineStart = Math.min(pendingSelection.start, pendingSelection.end); - const lineEnd = Math.max(pendingSelection.start, pendingSelection.end); + const sel = storeApi.getState().pendingSelection; + if (!sel) return; + const lineStart = Math.min(sel.start, sel.end); + const lineEnd = Math.max(sel.start, sel.end); const newAnnotation: CodeAnnotation = { id: generateId(), type, @@ -907,7 +984,7 @@ const ReviewApp: React.FC = () => { filePath, lineStart, lineEnd, - side: pendingSelection.side === 'additions' ? 'new' : 'old', + side: sel.side === 'additions' ? 'new' : 'old', text, suggestedCode, originalCode, @@ -921,9 +998,8 @@ const ReviewApp: React.FC = () => { conventionalLabel, decorations, }; - setAnnotations(prev => [...prev, withPRContext(newAnnotation)]); - setPendingSelection(null); - }, [pendingSelection, identity, withPRContext]); + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); const handleAddAnnotation = useCallback(( type: CodeAnnotationType, @@ -934,12 +1010,14 @@ const ReviewApp: React.FC = () => { decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta ) => { - if (!files[activeFileIndex]) return; - handleAddAnnotationForFile(files[activeFileIndex].path, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); - }, [files, activeFileIndex, handleAddAnnotationForFile]); + const { files: f, focusedFileIndex } = storeApi.getState(); + if (!f[focusedFileIndex]) return; + handleAddAnnotationForFile(f[focusedFileIndex].path, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); + }, [storeApi, handleAddAnnotationForFile]); const handleAddFileComment = useCallback((text: string) => { - const activeFile = files[activeFileIndex]; + const { files: f, focusedFileIndex } = storeApi.getState(); + const activeFile = f[focusedFileIndex]; const trimmed = text.trim(); if (!activeFile || !trimmed) return; @@ -956,8 +1034,8 @@ const ReviewApp: React.FC = () => { author: identity, }; - setAnnotations(prev => [...prev, withPRContext(newAnnotation)]); - }, [files, activeFileIndex, identity, withPRContext]); + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); const handleAddFileCommentForFile = useCallback((filePath: string, text: string) => { const trimmed = text.trim(); @@ -976,8 +1054,8 @@ const ReviewApp: React.FC = () => { author: identity, }; - setAnnotations(prev => [...prev, withPRContext(newAnnotation)]); - }, [identity, withPRContext]); + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); // Edit annotation const handleEditAnnotation = useCallback(( @@ -988,43 +1066,53 @@ const ReviewApp: React.FC = () => { conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[], ) => { - const ann = allAnnotationsRef.current.find(a => a.id === id); const updates: Partial = { ...(text !== undefined && { text }), ...(suggestedCode !== undefined && { suggestedCode }), ...(originalCode !== undefined && { originalCode }), - // null clears the label; undefined means "not provided, keep existing" ...(conventionalLabel !== undefined && { conventionalLabel: conventionalLabel ?? undefined }), ...(decorations !== undefined && { decorations }), }; - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + const state = storeApi.getState(); + const ann = selectAllAnnotations(state).find(a => a.id === id); + if (ann?.source && state.externalAnnotations.some(e => e.id === id)) { updateExternalAnnotation(id, updates); return; } - setAnnotations(prev => prev.map(a => - a.id === id ? { ...a, ...updates } : a - )); - }, [updateExternalAnnotation, externalAnnotations]); + state.editAnnotation(id, updates); + }, [storeApi, updateExternalAnnotation]); const handleDeleteAnnotation = useCallback((id: string) => { - const ann = allAnnotationsRef.current.find(a => a.id === id); - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + const state = storeApi.getState(); + const ann = selectAllAnnotations(state).find(a => a.id === id); + if (ann?.source && state.externalAnnotations.some(e => e.id === id)) { deleteExternalAnnotation(id); - if (selectedAnnotationId === id) setSelectedAnnotationId(null); + if (state.selectedAnnotationId === id) state.selectAnnotation(null); return; } - setAnnotations(prev => prev.filter(a => a.id !== id)); - if (selectedAnnotationId === id) { - setSelectedAnnotationId(null); - } - }, [selectedAnnotationId, deleteExternalAnnotation, externalAnnotations]); + state.deleteAnnotation(id); + }, [storeApi, deleteExternalAnnotation]); // Handle identity change - update author on existing annotations const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { - setAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); - }, []); + storeApi.getState().setLocalAnnotations( + storeApi.getState().localAnnotations.map(ann => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann + ), + ); + }, [storeApi]); + + // Re-tag annotations whenever identity changes anywhere (monolith Settings or + // the global AppSettingsDialog), via the decoupled identity-change event. + useEffect(() => { + const onIdentityChange = (e: Event) => { + const detail = (e as CustomEvent<{ oldId: string; newId: string }>).detail; + if (!detail) return; + handleIdentityChange(detail.oldId, detail.newId); + }; + window.addEventListener('plannotator:identity-change', onIdentityChange); + return () => window.removeEventListener('plannotator:identity-change', onIdentityChange); + }, [handleIdentityChange]); // Switch file in the dedicated center diff panel. const handleFilePreview = useCallback((index: number) => { @@ -1102,9 +1190,11 @@ const ReviewApp: React.FC = () => { useEffect(() => { const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return; if (!isDiffPanelActive) return; - const filePath = files[activeFileIndex]?.path; + const { files: f, focusedFileIndex } = storeApi.getState(); + const filePath = f[focusedFileIndex]?.path; if (!filePath) return; if (e.key === 'v') { @@ -1117,28 +1207,28 @@ const ReviewApp: React.FC = () => { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [files, activeFileIndex, isDiffPanelActive, handleToggleViewed, canStageFiles, stageFile]); + }, [storeApi, isDiffPanelActive, handleToggleViewed, canStageFiles, stageFile]); // Shared function: apply a PR response (used by both initial load and PR switch) function applyPRResponse(data: PRSessionUpdate & { rawPatch: string; gitRef: string; repoInfo?: { display: string; branch?: string }; - viewedFiles?: string[]; error?: string; + viewedFiles?: string[]; agentCwd?: string | null; error?: string; }) { const isPRSwitch = !!data.prMetadata; const nextFiles = parseDiffToFiles(data.rawPatch); dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); needsInitialDiffPanel.current = true; setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev); - setFiles(nextFiles); + const currentPath = files[activeFileIndex]?.path; + storeApi.getState().setFiles(nextFiles); if (isPRSwitch) { - setActiveFileIndex(0); + storeApi.getState().setFocusedFile(0); } else { - const currentFile = files[activeFileIndex]; - const preserved = currentFile ? nextFiles.findIndex(f => f.path === currentFile.path) : -1; - setActiveFileIndex(preserved >= 0 ? preserved : 0); + const preserved = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + storeApi.getState().setFocusedFile(preserved >= 0 ? preserved : 0); } - setPendingSelection(null); + storeApi.getState().setPendingSelection(null); updatePRSession({ ...(data.prMetadata && { prMetadata: data.prMetadata }), ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), @@ -1147,6 +1237,7 @@ const ReviewApp: React.FC = () => { ...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }), }); if (data.repoInfo) setRepoInfo(data.repoInfo); + if (data.agentCwd !== undefined) setAgentCwd(data.agentCwd); if (data.prMetadata) { setViewedFiles(data.viewedFiles ? new Set(data.viewedFiles) : new Set()); } @@ -1194,21 +1285,21 @@ const ReviewApp: React.FC = () => { // Whitespace toggle: update patch in-place, keep the active file. // If the current file was removed (whitespace-only), retarget the // dock panel to the first remaining file. + const currentPath = storeApi.getState().files[storeApi.getState().focusedFileIndex]?.path; setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev); - setFiles(nextFiles); - const currentPath = files[activeFileIndex]?.path; + storeApi.getState().setFiles(nextFiles); const nextIdx = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; if (nextIdx !== -1) { - setActiveFileIndex(nextIdx); + storeApi.getState().setFocusedFile(nextIdx); } else if (nextFiles.length > 0) { - setActiveFileIndex(0); + storeApi.getState().setFocusedFile(0); openDiffFile(nextFiles[0].path); } } else { dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); needsInitialDiffPanel.current = true; setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev); - setFiles(nextFiles); + storeApi.getState().setFiles(nextFiles); setDiffType(data.diffType); if (data.base) { setSelectedBase(data.base); @@ -1241,8 +1332,8 @@ const ReviewApp: React.FC = () => { }; }); } - setActiveFileIndex(0); - setPendingSelection(null); + storeApi.getState().setFocusedFile(0); + storeApi.getState().setPendingSelection(null); resetStagedFiles(); } setDiffError(data.error || null); @@ -1254,7 +1345,7 @@ const ReviewApp: React.FC = () => { } finally { setIsLoadingDiff(false); } - }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile]); + }, [storeApi, dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, openDiffFile]); // Switch the base branch the current diff compares against. // Only triggers a refetch when the active mode actually uses a base. @@ -1323,28 +1414,26 @@ const ReviewApp: React.FC = () => { // Select annotation - switches file if needed and scrolls to it const handleSelectAnnotation = useCallback((id: string | null) => { if (!id) { - setSelectedAnnotationId(null); + storeApi.getState().selectAnnotation(null); return; } - // Find the annotation - const annotation = allAnnotations.find(a => a.id === id); + const state = storeApi.getState(); + const annotation = selectAllAnnotations(state).find(a => a.id === id); if (!annotation) { - setSelectedAnnotationId(id); + state.selectAnnotation(id); return; } - // In all-files mode, just set the selection — the panel's scroll-to-annotation - // effect handles expanding and scrolling. In single-file mode, switch to the file. - if (!isAllFilesActive) { - const fileIndex = files.findIndex(f => f.path === annotation.filePath); + if (!state.isAllFilesActive) { + const fileIndex = state.files.findIndex(f => f.path === annotation.filePath); if (fileIndex !== -1) { handleFileSwitch(fileIndex); } } - setSelectedAnnotationId(id); - }, [allAnnotations, files, isAllFilesActive, handleFileSwitch]); + state.selectAnnotation(id); + }, [storeApi, handleFileSwitch]); // Diff context bundled into local-mode feedback headers so the receiving // agent knows which diff the annotations are anchored to. Uses committedBase @@ -1466,7 +1555,7 @@ const ReviewApp: React.FC = () => { handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol, ]); - // Separate context for high-frequency job logs — prevents re-rendering all panels on every SSE event + // Separate context for high-frequency job logs — prevents re-rendering all panels on every live event const jobLogsValue = useMemo(() => ({ jobLogs: agentJobs.jobLogs }), [agentJobs.jobLogs]); // Copy raw diff to clipboard @@ -1533,7 +1622,16 @@ const ReviewApp: React.FC = () => { }), }); if (res.ok) { - setSubmitted('feedback'); + const data = await res.json().catch(() => ({})); + if (data.feedbackDelivered) { + setFeedbackSent(true); + setIsSendingFeedback(false); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + } else { + setSubmitted('feedback'); + } } else { throw new Error('Failed to send'); } @@ -1543,7 +1641,7 @@ const ReviewApp: React.FC = () => { setTimeout(() => setCopyFeedback(null), 2000); setIsSendingFeedback(false); } - }, [totalAnnotationCount, feedbackMarkdown, allAnnotations]); + }, [totalAnnotationCount, feedbackMarkdown, allAnnotations, storeApi]); // Exit review session without sending any feedback const handleExit = useCallback(async () => { @@ -1703,6 +1801,7 @@ const ReviewApp: React.FC = () => { const DOUBLE_TAP_WINDOW = 300; const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 'Alt' || e.repeat) return; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; @@ -1735,11 +1834,12 @@ const ReviewApp: React.FC = () => { // Cmd/Ctrl+Enter keyboard shortcut to approve or send feedback useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; // If the platform post dialog is open, Cmd+Enter submits it if (platformCommentDialog) { - if (submitted || isPlatformActioning) return; + if (submitted || feedbackSent || isPlatformActioning) return; const isApproveAction = platformCommentDialog.action === 'approve'; const hasTargets = platformCommentDialog.plan.targets.length > 0; const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); @@ -1752,7 +1852,7 @@ const ReviewApp: React.FC = () => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return; - if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; + if (submitted || feedbackSent || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; if (!origin) return; // Demo mode e.preventDefault(); @@ -1780,31 +1880,48 @@ const ReviewApp: React.FC = () => { }, [ showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning, platformCommentDialog, platformGeneralComment, - submitted, isSendingFeedback, isApproving, isExiting, isPlatformActioning, + submitted, feedbackSent, isSendingFeedback, isApproving, isExiting, isPlatformActioning, origin, platformMode, platformLabel, platformUser, prMetadata, totalAnnotationCount, openPlatformDialog, handleApprove, handleSendFeedback, handlePlatformAction ]); if (isLoading) { - return ( - -
-
Loading diff...
-
-
+ const skeleton = ( +
+
Loading diff...
+
); + if (__embedded) return skeleton; + return {skeleton}; } - return ( - - + const completionTitle = !submitted ? '' : + submitted === 'approved' ? 'Changes Approved' + : submitted === 'exited' ? 'Session Closed' + : 'Feedback Sent'; + const completionSubtitle = !submitted ? '' : + submitted === 'exited' + ? 'Review session closed without feedback.' + : platformMode + ? submitted === 'approved' + ? `Your approval was submitted to ${platformLabel}.` + : `Your feedback was submitted to ${platformLabel}.` + : submitted === 'approved' + ? `${getAgentName(origin)} will proceed with the changes.` + : `${getAgentName(origin)} will address your review feedback.`; + + const innerContent = ( {isSwitchingPRScope && } -
+
{/* Header */} -
-
+
+
+ {headerLeft} + {headerLeft && shouldShowFileTree && ( +
+ )} {shouldShowFileTree && ( <>
@@ -1909,7 +2024,7 @@ const ReviewApp: React.FC = () => {
- {origin ? ( + {origin && !submitted && !feedbackSent ? ( <> {/* Destination dropdown (PR mode only) */} {prMetadata && ( @@ -2072,21 +2187,6 @@ const ReviewApp: React.FC = () => {
- setOpenSettingsMenu(true)} - onOpenExport={() => setShowExportModal(true)} - onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} - onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} - isFileTreeOpen={isFileTreeOpen} - isSidebarOpen={reviewSidebar.isOpen} - appVersion={appVersion} - updateInfo={updateInfo} - origin={origin} - isWSL={isWSL} - /> - -
- {/* Sidebar tab toggles */} )} + +
+ + externalOpenSettings?.()} + onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} + appVersion={appVersion} + updateInfo={updateInfo} + origin={origin} + isWSL={isWSL} + />
+ {/* Embedded completion banner — inline, non-blocking */} + {__embedded && !legacyTabMode && ( + + )} + {/* Main content */} -
+
{shouldShowFileTree && isFileTreeOpen && ( <> { onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined} repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)} /> - + setIsFileTreeOpen(false)} /> )} {/* Center dock area */}
- { - const parts: string[] = []; - if (draftBanner.count > 0) parts.push(`${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''}`); - if (draftBanner.viewedCount > 0) parts.push(`${draftBanner.viewedCount} viewed file${draftBanner.viewedCount !== 1 ? 's' : ''}`); - return `Found ${parts.join(' and ')} from ${draftBanner.timeAgo}. Would you like to restore them?`; - })() : ''} - confirmText="Restore" - cancelText="Dismiss" - showCancel - /> {files.length > 0 ? ( { {/* Resize Handle + Sidebar */} {reviewSidebar.isOpen && ( <> - + {
)} - - {/* Worktree info dialog */} {(gitContext?.cwd || agentCwd) && prMetadata && ( { /> )} - {/* Completion overlay - shown after approve/feedback/exit */} - + {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} + {(!__embedded || legacyTabMode) && ( + + )} {/* GitHub general comment dialog */} { )} - + {!__embedded && ( + + )} - + ); + + if (__embedded) return innerContent; + + return ( + + + {innerContent} + ); }; -export default ReviewApp; +export default function ReviewAppStandalone(props: { __embedded?: boolean; headerLeft?: React.ReactNode; onOpenSettings?: () => void }) { + return ; +} + +export function ReviewAppEmbedded({ headerLeft, onOpenSettings }: { headerLeft?: React.ReactNode; onOpenSettings?: () => void }) { + return ( + + + + ); +} + diff --git a/packages/review-editor/components/AIConfigBar.tsx b/packages/plannotator-code-review/components/AIConfigBar.tsx similarity index 100% rename from packages/review-editor/components/AIConfigBar.tsx rename to packages/plannotator-code-review/components/AIConfigBar.tsx diff --git a/packages/review-editor/components/AITab.tsx b/packages/plannotator-code-review/components/AITab.tsx similarity index 100% rename from packages/review-editor/components/AITab.tsx rename to packages/plannotator-code-review/components/AITab.tsx diff --git a/packages/review-editor/components/AgentReviewActions.tsx b/packages/plannotator-code-review/components/AgentReviewActions.tsx similarity index 100% rename from packages/review-editor/components/AgentReviewActions.tsx rename to packages/plannotator-code-review/components/AgentReviewActions.tsx diff --git a/packages/review-editor/components/AllFilesDiffView.tsx b/packages/plannotator-code-review/components/AllFilesDiffView.tsx similarity index 96% rename from packages/review-editor/components/AllFilesDiffView.tsx rename to packages/plannotator-code-review/components/AllFilesDiffView.tsx index a255531d8..ca5978375 100644 --- a/packages/review-editor/components/AllFilesDiffView.tsx +++ b/packages/plannotator-code-review/components/AllFilesDiffView.tsx @@ -361,7 +361,7 @@ export const AllFilesDiffView: React.FC = ({ return () => root.removeEventListener('mouseup', handler, true); }, [sortedFiles, activeFilePath]); - // Scroll to selected annotation — auto-expand collapsed file + // Scroll to selected annotation — auto-expand collapsed file, then scroll to the annotation element useEffect(() => { if (!selectedAnnotationId) return; const ann = annotations.find(a => a.id === selectedAnnotationId); @@ -372,10 +372,21 @@ export const AllFilesDiffView: React.FC = ({ next.delete(ann.filePath); return next; }); - requestAnimationFrame(() => { - const header = headerRefs.current.get(ann.filePath); - header?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); + const scrollToAnnotation = (attempt = 0) => { + const el = scrollRef.current?.querySelector(`[data-annotation-id="${selectedAnnotationId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + // Fall back to file header, or retry if the diff is still lazy-mounting + if (attempt < 3) { + requestAnimationFrame(() => scrollToAnnotation(attempt + 1)); + } else { + const header = headerRefs.current.get(ann.filePath); + header?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + requestAnimationFrame(() => scrollToAnnotation()); }, [selectedAnnotationId, annotations]); return ( diff --git a/packages/review-editor/components/AnnotationToolbar.tsx b/packages/plannotator-code-review/components/AnnotationToolbar.tsx similarity index 100% rename from packages/review-editor/components/AnnotationToolbar.tsx rename to packages/plannotator-code-review/components/AnnotationToolbar.tsx diff --git a/packages/review-editor/components/AskAIInput.tsx b/packages/plannotator-code-review/components/AskAIInput.tsx similarity index 100% rename from packages/review-editor/components/AskAIInput.tsx rename to packages/plannotator-code-review/components/AskAIInput.tsx diff --git a/packages/review-editor/components/BaseBranchPicker.tsx b/packages/plannotator-code-review/components/BaseBranchPicker.tsx similarity index 100% rename from packages/review-editor/components/BaseBranchPicker.tsx rename to packages/plannotator-code-review/components/BaseBranchPicker.tsx diff --git a/packages/review-editor/components/ConventionalLabelPicker.tsx b/packages/plannotator-code-review/components/ConventionalLabelPicker.tsx similarity index 100% rename from packages/review-editor/components/ConventionalLabelPicker.tsx rename to packages/plannotator-code-review/components/ConventionalLabelPicker.tsx diff --git a/packages/review-editor/components/CopyButton.tsx b/packages/plannotator-code-review/components/CopyButton.tsx similarity index 100% rename from packages/review-editor/components/CopyButton.tsx rename to packages/plannotator-code-review/components/CopyButton.tsx diff --git a/packages/review-editor/components/CountBadge.tsx b/packages/plannotator-code-review/components/CountBadge.tsx similarity index 100% rename from packages/review-editor/components/CountBadge.tsx rename to packages/plannotator-code-review/components/CountBadge.tsx diff --git a/packages/review-editor/components/DiffHunkPreview.tsx b/packages/plannotator-code-review/components/DiffHunkPreview.tsx similarity index 92% rename from packages/review-editor/components/DiffHunkPreview.tsx rename to packages/plannotator-code-review/components/DiffHunkPreview.tsx index 1731b5f30..300417793 100644 --- a/packages/review-editor/components/DiffHunkPreview.tsx +++ b/packages/plannotator-code-review/components/DiffHunkPreview.tsx @@ -4,7 +4,7 @@ import { getSingularPatch } from '@pierre/diffs'; import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { useConfigValue } from '@plannotator/ui/config'; -import { useReviewState } from '../dock/ReviewStateContext'; +import { useReviewStore } from '../store'; import { resolveSyntaxTheme, buildLineBgOverrides } from '../hooks/usePierreTheme'; interface DiffHunkPreviewProps { @@ -69,7 +69,8 @@ export const DiffHunkPreview: React.FC = ({ className, }) => { const { resolvedMode, colorTheme } = useTheme(); - const state = useReviewState(); + const fontFamily = useReviewStore(s => s.fontFamily); + const fontSize = useReviewStore(s => s.fontSize); const lineBgIntensity = useConfigValue('diffLineBgIntensity'); const [expanded, setExpanded] = useState(false); @@ -95,7 +96,7 @@ export const DiffHunkPreview: React.FC = ({ // The lazy initializer reads computed CSS variables from the document root. const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>(() => ({ type: resolvedMode ?? 'dark', - css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize, lineBgIntensity), + css: buildPierreCSS(resolvedMode ?? 'dark', fontFamily, fontSize, lineBgIntensity), })); // Re-compute on theme / font / intensity changes @@ -103,11 +104,11 @@ export const DiffHunkPreview: React.FC = ({ const rafId = requestAnimationFrame(() => { setPierreTheme({ type: resolvedMode ?? 'dark', - css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize, lineBgIntensity), + css: buildPierreCSS(resolvedMode ?? 'dark', fontFamily, fontSize, lineBgIntensity), }); }); return () => cancelAnimationFrame(rafId); - }, [resolvedMode, colorTheme, state.fontFamily, state.fontSize, lineBgIntensity]); + }, [resolvedMode, colorTheme, fontFamily, fontSize, lineBgIntensity]); const syntaxTheme = resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark'); diff --git a/packages/review-editor/components/DiffOptionsPopover.tsx b/packages/plannotator-code-review/components/DiffOptionsPopover.tsx similarity index 89% rename from packages/review-editor/components/DiffOptionsPopover.tsx rename to packages/plannotator-code-review/components/DiffOptionsPopover.tsx index e2ae27015..14a4aca89 100644 --- a/packages/review-editor/components/DiffOptionsPopover.tsx +++ b/packages/plannotator-code-review/components/DiffOptionsPopover.tsx @@ -7,7 +7,7 @@ import { INDICATOR_OPTIONS, LINE_DIFF_OPTIONS, LINE_BG_INTENSITY_OPTIONS, -} from '@plannotator/ui/components/Settings'; +} from '@plannotator/ui/components/settings/diffOptions'; function CompactSegmented({ options, value, onChange }: { options: { value: T; label: string }[]; @@ -123,41 +123,41 @@ export const DiffOptionsPopover: React.FC = () => {
Layout
- configStore.set('diffStyle', v)} /> + configStore.getState().set('diffStyle', v)} />
- configStore.set('diffOverflow', v)} /> + configStore.getState().set('diffOverflow', v)} />
Indicators
- configStore.set('diffIndicators', v)} /> + configStore.getState().set('diffIndicators', v)} />
Inline diff
- configStore.set('diffLineDiffType', v)} /> + configStore.getState().set('diffLineDiffType', v)} />
- configStore.set('diffShowLineNumbers', v)} label="Line numbers" /> - configStore.set('diffShowBackground', v)} label="Diff background" /> + configStore.getState().set('diffShowLineNumbers', v)} label="Line numbers" /> + configStore.getState().set('diffShowBackground', v)} label="Diff background" /> {diffShowBackground && (
- configStore.set('diffLineBgIntensity', v)} /> + configStore.getState().set('diffLineBgIntensity', v)} />
)} - configStore.set('diffHideWhitespace', v)} label="Hide whitespace" /> + configStore.getState().set('diffHideWhitespace', v)} label="Hide whitespace" /> configStore.set('diffTabSize', v)} + onChange={(v) => configStore.getState().set('diffTabSize', v)} />
diff --git a/packages/review-editor/components/DiffTypePicker.tsx b/packages/plannotator-code-review/components/DiffTypePicker.tsx similarity index 100% rename from packages/review-editor/components/DiffTypePicker.tsx rename to packages/plannotator-code-review/components/DiffTypePicker.tsx diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/plannotator-code-review/components/DiffViewer.tsx similarity index 99% rename from packages/review-editor/components/DiffViewer.tsx rename to packages/plannotator-code-review/components/DiffViewer.tsx index a4181b0fc..fc4dc2e97 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/plannotator-code-review/components/DiffViewer.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react'; import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react'; import { getSingularPatch, processFile } from '@pierre/diffs'; +import { useSessionFetch } from '@plannotator/ui/hooks/useSessionFetch'; import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; import type { DiffTokenEventBaseProps } from '@pierre/diffs'; import { usePierreTheme } from '../hooks/usePierreTheme'; @@ -211,6 +212,7 @@ export const DiffViewer: React.FC = ({ aiHistoryMessages = [], onCodeNavRequest, }) => { + const fetch = useSessionFetch(); const pierreTheme = usePierreTheme({ fontFamily, fontSize }); // containerRef must point at the actual scrolling element (the // OverlayScrollbars viewport), not the OverlayScrollArea host. `viewport` diff --git a/packages/review-editor/components/EvoLogPicker.tsx b/packages/plannotator-code-review/components/EvoLogPicker.tsx similarity index 100% rename from packages/review-editor/components/EvoLogPicker.tsx rename to packages/plannotator-code-review/components/EvoLogPicker.tsx diff --git a/packages/review-editor/components/FileHeader.tsx b/packages/plannotator-code-review/components/FileHeader.tsx similarity index 100% rename from packages/review-editor/components/FileHeader.tsx rename to packages/plannotator-code-review/components/FileHeader.tsx diff --git a/packages/review-editor/components/FileTree.tsx b/packages/plannotator-code-review/components/FileTree.tsx similarity index 99% rename from packages/review-editor/components/FileTree.tsx rename to packages/plannotator-code-review/components/FileTree.tsx index f5c3d6022..5805b2a98 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/plannotator-code-review/components/FileTree.tsx @@ -226,11 +226,11 @@ export const FileTree: React.FC = ({ }, [allFolderPaths, areAllFoldersExpanded]); return ( -