diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d5571467..1db79114f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,22 +170,29 @@ jobs: local ok=0 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. @@ -232,9 +239,14 @@ jobs: 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 1 + $sessionsBody = $sessionsResponse.Content | ConvertFrom-Json + if ($sessionsBody.sessions.Count -gt 0) { + $sessionUrl = $sessionsBody.sessions[0].url + Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null + $ok = $true + break + } } catch { if ($process.HasExited) { break @@ -247,6 +259,7 @@ jobs: Stop-Process -Id $process.Id -Force Wait-Process -Id $process.Id -ErrorAction SilentlyContinue } + & $binary daemon stop *> $null Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue } @@ -255,10 +268,10 @@ jobs: 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. diff --git a/.gitignore b/.gitignore index ccdb004de..9d3f75a12 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ opencode.json plannotator-local # Local research/reference docs (not for repo) /reference/ -# Local goal setup packages generated by the setup-goal skill. -/goals/ *.bun-build diff --git a/AGENTS.md b/AGENTS.md index de0f32123..a570a9ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ plannotator/ │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() │ │ ├── annotate.ts # startAnnotateServer(), 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() @@ -99,6 +100,8 @@ Plannotator has one server implementation: Claude Code runs this server through the released `plannotator` binary entrypoint. OpenCode and Pi do not package their own server implementations; they call the same binary through the plugin protocol in `packages/shared/plugin-protocol.ts`. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/`. +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/archive 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 **Via plugin marketplace** (when repo is public): @@ -133,6 +136,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. @@ -216,6 +220,49 @@ During normal plan review, an Archive sidebar tab provides the same browsing via ## Server API +### Daemon Runtime (`packages/server/daemon/`) + +The daemon is the single long-running Bun server used by normal plan/review/annotate/archive 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. + +| 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/archive 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/vaults` | GET | Detect available Obsidian vaults | +| `/daemon/obsidian/vaults` | GET | Alias for `/daemon/vaults` | +| `/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:project:branch` for 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. + +**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 | @@ -239,8 +286,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | | `/api/editor-annotations` | GET | List editor annotations (VS Code only) | | `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | -| `/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 | @@ -265,14 +311,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 | @@ -288,7 +332,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 | @@ -297,8 +343,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=&base=`) | | `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) | | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | -| `/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 | 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..70e66e7ca --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,34 @@ +# @plannotator/debug-frontend + +Debug/development harness UI for the Plannotator daemon runtime. **Not production code** — this is a +testbed for exercising daemon sessions, verifying event streams, and testing session lifecycle actions. + +## 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`, `src/archive`, 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/debug-frontend dev +bun run --cwd apps/debug-frontend build +bun run --cwd apps/debug-frontend check +bun run --cwd apps/debug-frontend test:browser +``` + +Or from the repo root: + +```bash +bun run dev:debug-frontend +bun run build:debug-frontend +bun run check:debug-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..061a8540a --- /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..137408b26 --- /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/debug-frontend/dist/index.html to exist after build."); +} + +const html = readFileSync(indexPath, "utf-8"); + +const outputFiles = listFiles(distDir) + .map((file) => relative(distDir, file)) + .sort(); +const extraFiles = outputFiles.filter((file) => file !== "index.html"); + +if (extraFiles.length > 0) { + throw new Error( + `Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`, + ); +} + +const htmlWithoutInlineCode = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + +const externalScriptPattern = /]*\bsrc=["'][^"']+["']/i; +const externalLinkPatterns = [ + /]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i, + /]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i, +]; + +if ( + externalScriptPattern.test(html) || + externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode)) +) { + throw new Error("Frontend daemon shell build must inline scripts and styles."); +} + +console.log("Verified single-file frontend shell build."); diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx new file mode 100644 index 000000000..071f13d0d --- /dev/null +++ b/apps/frontend/src/app/Layout.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect } from "react"; +import { Outlet, useMatchRoute } from "@tanstack/react-router"; +import { Toaster } from "sonner"; +import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AppSidebar } from "../components/sidebar/AppSidebar"; +import { SidebarPeek } from "../components/sidebar/SidebarPeek"; +import { AddProjectDialog } from "../components/landing/AddProjectDialog"; +import { AppSettingsDialog } from "../components/settings/AppSettingsDialog"; +import { SessionSurface } from "../components/sessions/SessionSurface"; +import { appStore } from "../stores/app-store"; +import { setGlobalFetchBase } from "@plannotator/ui/utils/api"; +import { useDaemonEvents } from "../daemon/events/use-daemon-events"; + +setGlobalFetchBase("/daemon"); +import { projectStore } from "../stores/project-store"; +import { useAppStore } from "../stores/app-store"; + +function LayoutContent() { + const addProjectOpen = useAppStore((s) => s.addProjectOpen); + const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen); + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const matchRoute = useMatchRoute(); + const { open: sidebarOpen } = useSidebar(); + + const { reportActiveSession } = useDaemonEvents(); + + useEffect(() => { + void projectStore.getState().fetchProjects(); + }, []); + + const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + + useEffect(() => { + reportActiveSession(isOnSession ? activeSessionId : null); + }, [reportActiveSession, isOnSession, activeSessionId]); + const showLanding = !isOnSession; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + const current = appStore.getState().settingsOpen; + appStore.getState().setSettingsOpen(!current); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + return ( + <> + + +
+
+ +
+ + {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => { + const isActive = sessionId === activeSessionId && isOnSession; + return ( +
+ +
+ ); + })} +
+ + + + + ); +} + +export function Layout() { + const matchRoute = useMatchRoute(); + const initiallyOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + + return ( + + + + + + ); +} diff --git a/apps/frontend/src/app/router.tsx b/apps/frontend/src/app/router.tsx new file mode 100644 index 000000000..3693e34c7 --- /dev/null +++ b/apps/frontend/src/app/router.tsx @@ -0,0 +1,25 @@ +import { createRouter } from "@tanstack/react-router"; +import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client"; +import { routeTree } from "../routeTree.gen"; + +export interface AppRouterContext { + daemonClient: DaemonApiClient; +} + +export function createAppRouter( + context: AppRouterContext = { daemonClient: createDaemonApiClient() }, +) { + return createRouter({ + routeTree, + context, + defaultPreload: "intent", + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/frontend/src/assets/sprite_package_sidebar/sprite.png b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png new file mode 100644 index 000000000..a209d60cc Binary files /dev/null and b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png differ diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx new file mode 100644 index 000000000..88fb4b00a --- /dev/null +++ b/apps/frontend/src/components/landing/AddProjectDialog.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Folder, ChevronRight, X, CornerDownLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "../../stores/project-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import type { DirectoryEntry, ProjectEntry } from "../../daemon/contracts"; + +interface AddProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddProjectDialog({ open, onOpenChange }: AddProjectDialogProps) { + const [query, setQuery] = useState("~"); + const [resolvedPath, setResolvedPath] = useState(""); + const [dirs, setDirs] = useState([]); + const [loading, setLoading] = useState(false); + const [adding, setAdding] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const projects = useProjectStore((s) => s.projects); + const addProject = useProjectStore((s) => s.addProject); + const inputRef = useRef(null); + const listRef = useRef(null); + + const recentProjects = projects.slice(0, 5); + + const fetchDirs = useCallback(async (path: string) => { + setLoading(true); + const result = await daemonApiClient.listDirectories(path); + if (result.ok) { + setResolvedPath(result.data.path); + setDirs(result.data.dirs); + } else { + setDirs([]); + } + setLoading(false); + setActiveIndex(0); + }, []); + + useEffect(() => { + if (!open) return; + setQuery("~"); + setDirs([]); + setResolvedPath(""); + setActiveIndex(0); + fetchDirs("~"); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [open, fetchDirs]); + + useEffect(() => { + if (!open) return; + const timer = setTimeout(() => { + if (query.trim()) fetchDirs(query.trim()); + }, 150); + return () => clearTimeout(timer); + }, [query, open, fetchDirs]); + + const handleSelect = useCallback( + async (path: string) => { + setAdding(true); + const result = await addProject(path); + setAdding(false); + if (result) { + onOpenChange(false); + } + }, + [addProject, onOpenChange], + ); + + const handleNavigate = useCallback( + (path: string) => { + setQuery(path); + fetchDirs(path); + }, + [fetchDirs], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const totalItems = recentProjects.length + dirs.length; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % totalItems); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + totalItems) % totalItems); + } else if (e.key === "Tab" && !e.shiftKey && dirs.length > 0) { + e.preventDefault(); + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleNavigate(dirs[dirIndex].path); + } else if (dirs.length > 0) { + handleNavigate(dirs[0].path); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (activeIndex < recentProjects.length) { + handleSelect(recentProjects[activeIndex].cwd); + } else { + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleSelect(dirs[dirIndex].path); + } else if (resolvedPath) { + handleSelect(resolvedPath); + } + } + } else if (e.key === "Escape") { + onOpenChange(false); + } + }, + [activeIndex, dirs, recentProjects, resolvedPath, handleNavigate, handleSelect, onOpenChange], + ); + + useEffect(() => { + const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + if (!open) return null; + + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="~/work/project or search…" + autoComplete="off" + spellCheck={false} + className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-[13px]" + /> + {adding && Adding…} + +
+ +
+ {recentProjects.length > 0 && ( +
+ + Recent + + {recentProjects.map((project, i) => ( + handleSelect(project.cwd)} + onHover={() => setActiveIndex(i)} + /> + ))} +
+ )} + +
+ {recentProjects.length > 0 && dirs.length > 0 && ( + + Directories + + )} + {dirs.map((dir, i) => { + const idx = recentProjects.length + i; + return ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(idx)} + /> + ); + })} + {!loading && dirs.length === 0 && recentProjects.length === 0 && ( +
+ No directories found +
+ )} +
+
+ +
+ + select + + + Tab navigate into + + + Esc close + +
+
+
+ ); +} + +function ProjectRow({ + project, + active, + index, + onSelect, + onHover, +}: { + project: ProjectEntry; + active: boolean; + index: number; + onSelect: () => void; + onHover: () => void; +}) { + return ( + + ); +} + +function DirectoryRow({ + dir, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx new file mode 100644 index 000000000..1ca7ba6f0 --- /dev/null +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -0,0 +1,818 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { + Code2, + Archive, + Folder, + FolderPlus, + ChevronRight, + ChevronDown, + Trash2, +} from "lucide-react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import { ASCII_BANNER } from "./ascii-banner"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useProjectStore, projectStore } from "../../stores/project-store"; +import { GitDashboard } from "./git-dashboard/GitDashboard"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import type { + ProjectEntry, + PRListItem, + SessionSummary, + WorktreeEntry, +} from "../../daemon/contracts"; + +interface LandingPageProps { + onAddProject: () => void; +} + +interface Selection { + key: string; + cwd: string; + label: string; + prUrl?: string; +} + +function selectionKey(sel: Omit): string { + return sel.prUrl ?? sel.cwd; +} + +export function LandingPage({ onAddProject }: LandingPageProps) { + const projects = useProjectStore((s) => s.projects); + const sessions = useDaemonEventStore((s) => s.sessions); + const [selections, setSelections] = useState>(new Map()); + useEffect(() => { + const cwds = new Set(projects.map((p) => p.cwd)); + setSelections((prev) => { + const next = new Map(); + for (const [k, sel] of prev) { + if (cwds.has(sel.cwd)) next.set(k, sel); + } + return next.size === prev.size ? prev : next; + }); + }, [projects]); + const [loading, setLoading] = useState(null); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); + const navigate = useNavigate(); + + const toggleSelection = useCallback((sel: Omit) => { + setSelections((prev) => { + const key = selectionKey(sel); + const next = new Map(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { ...sel, key }); + } + return next; + }); + }, []); + + const selectionCount = selections.size; + + const handleAction = useCallback( + async (action: "review" | "archive") => { + if (selectionCount === 0) return; + setLoading(action); + let items = [...selections.values()]; + if (action === "archive") { + const seen = new Set(); + items = items.filter((sel) => { + if (seen.has(sel.cwd)) return false; + seen.add(sel.cwd); + return true; + }); + } + + const results = await Promise.allSettled( + items.map(async (sel) => { + const result = + action === "review" + ? await daemonApiClient.createReviewSession(sel.cwd, sel.prUrl) + : await daemonApiClient.createArchiveSession(sel.cwd); + return { sel, result }; + }), + ); + setLoading(null); + + let firstSessionId: string | null = null; + let successCount = 0; + const failures: { label: string; message: string }[] = []; + + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value.result.ok) { + successCount++; + if (!firstSessionId) firstSessionId = outcome.value.result.data.session.id; + } else { + const label = outcome.status === "fulfilled" ? outcome.value.sel.label : "Unknown"; + const message = + outcome.status === "fulfilled" && !outcome.value.result.ok + ? outcome.value.result.error.message + : outcome.status === "rejected" + ? String(outcome.reason) + : "Unknown error"; + failures.push({ label, message }); + } + } + + if (firstSessionId) { + setSelections(new Map()); + void navigate({ to: "/s/$sessionId", params: { sessionId: firstSessionId } }); + if (successCount > 1) { + toast.success(`Launched ${successCount} sessions`); + } + } + + for (const fail of failures) { + toast.error(fail.label, { description: fail.message }); + } + }, + [selections, selectionCount, navigate], + ); + + return ( +
+
+
+
+ +
+
+
+
+
+ + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( +
+ {projects.length > 0 && ( +
+
+ + Select project + + +
+ + +
+ + Launch + +
+ + + +
+
+
+ )} + + {sessions.length > 0 && ( +
+
+ Active sessions +
+ +
+ )} + + {projects.length === 0 && ( + + )} +
+ )} +
+
+
+
+ setViewIndex(0)} /> +
+
+
+
+
+ ); +} + +function ProjectTable({ + projects, + selections, + onToggle, +}: { + projects: ProjectEntry[]; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const topLevel = projects.filter((p) => !p.parentCwd); + const worktreeChildren = (parentCwd: string) => projects.filter((p) => p.parentCwd === parentCwd); + + return ( +
+ {topLevel.map((project, i) => { + const children = worktreeChildren(project.cwd); + return ( + + ); + })} +
+ ); +} + +function ProjectNode({ + project, + children, + isFirst, + selections, + onToggle, +}: { + project: ProjectEntry; + children: ProjectEntry[]; + isFirst: boolean; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [worktreesFetched, setWorktreesFetched] = useState(false); + const [prs, setPrs] = useState([]); + const [prPlatform, setPrPlatform] = useState(null); + const [prDefaultBranch, setPrDefaultBranch] = useState("main"); + const [prError, setPrError] = useState(null); + const [prsLoading, setPrsLoading] = useState(false); + const [prsFetchedAt, setPrsFetchedAt] = useState(0); + const hasChildren = children.length > 0; + + useEffect(() => { + if (!expanded || worktreesFetched) return; + setWorktreesFetched(true); + daemonApiClient.listWorktrees(project.cwd).then((result) => { + if (result.ok) { + setWorktrees(result.data.worktrees.filter((wt) => wt.path !== project.cwd)); + } + }); + }, [expanded, project.cwd, worktreesFetched]); + + useEffect(() => { + if (!expanded) return; + const stale = !prsFetchedAt || Date.now() - prsFetchedAt > 30_000; + if (!stale || prsLoading) return; + setPrsLoading(true); + daemonApiClient.listPRs(project.cwd).then((result) => { + if (result.ok) { + setPrs(result.data.prs); + setPrPlatform(result.data.platform); + if (result.data.defaultBranch) setPrDefaultBranch(result.data.defaultBranch); + setPrError(result.data.error ?? null); + } + setPrsLoading(false); + setPrsFetchedAt(Date.now()); + }); + }, [expanded, project.cwd, prsFetchedAt, prsLoading]); + + const hasWorktrees = hasChildren || worktrees.length > 0; + const isSelected = selections.has(project.cwd); + + const handleRemove = useCallback(async () => { + const ok = window.confirm( + `Remove "${project.name}"?\n\nThis will cancel active sessions and delete plan history for this project.`, + ); + if (!ok) return; + const removed = await projectStore.getState().removeProject(project.cwd, true); + if (removed) { + toast.success(`Removed ${project.name}`); + } else { + toast.error(`Failed to remove ${project.name}`); + } + }, [project.name, project.cwd]); + + return ( + + +
+
+ + +
+ + {expanded && ( +
+ + + + PRs + + + Worktrees + + + + + + + + + +
+ )} +
+
+ + + + + Remove project + + + +
+ ); +} + +interface PRStack { + prs: PRListItem[]; + label: string; +} + +function buildStacks( + prs: PRListItem[], + defaultBranch: string, +): { stacks: PRStack[]; loose: PRListItem[] } { + const byHead = new Map(); + for (const pr of prs) byHead.set(pr.headBranch, pr); + + const stacked = new Set(); + const chains: PRListItem[][] = []; + + for (const pr of prs) { + if (stacked.has(pr.id)) continue; + if (pr.baseBranch === defaultBranch) continue; + + const chain: PRListItem[] = []; + let current: PRListItem | undefined = pr; + 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 { + stacked.delete(pr.id); + } + } + + const stacks = chains.map((chain) => ({ + prs: chain, + label: `#${chain[0].number} → #${chain[chain.length - 1].number}`, + })); + const loose = prs.filter((pr) => !stacked.has(pr.id)); + return { stacks, loose }; +} + +function PRRow({ + pr, + projectCwd, + projectName, + selections, + onToggle, +}: { + pr: PRListItem; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + return ( + + ); +} + +function StackIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function StackGroup({ + stack, + projectCwd, + projectName, + selections, + onToggle, +}: { + stack: PRStack; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {stack.prs.map((pr) => ( + + ))} +
+ )} +
+ ); +} + +function PRList({ + prs, + loading, + error, + platform, + defaultBranch, + projectCwd, + projectName, + selections, + onToggle, +}: { + prs: PRListItem[]; + loading: boolean; + error: string | null; + platform: string | null; + defaultBranch: string; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [showAll, setShowAll] = useState(false); + const visible = useMemo( + () => (showAll ? prs : prs.filter((pr) => pr.state === "open")), + [prs, showAll], + ); + const hiddenCount = prs.length - visible.length; + const { stacks, loose } = useMemo( + () => buildStacks(visible, defaultBranch), + [visible, defaultBranch], + ); + + 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 (platform === "gitlab" && prs.length === 0) { + return ( +
GitLab MR listing coming soon
+ ); + } + if (visible.length === 0 && !showAll) { + return ( +
+ No open pull requests + {hiddenCount > 0 && ( + <> + {" · "} + + + )} +
+ ); + } + + return ( +
+ {stacks.map((stack) => ( + + ))} + {loose.map((pr) => ( + + ))} + {!showAll && hiddenCount > 0 && ( + + )} +
+ ); +} + +function WorktreeList({ + children, + worktrees, + hasWorktrees, + projectName, + selections, + onToggle, +}: { + children: ProjectEntry[]; + worktrees: WorktreeEntry[]; + hasWorktrees: boolean; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + if (!hasWorktrees) { + return
No worktrees
; + } + + const allWorktrees: { path: string; branch: string }[] = []; + for (const child of children) { + allWorktrees.push({ path: child.cwd, branch: child.branch ?? child.name }); + } + for (const wt of worktrees) { + if (!children.some((c) => c.cwd === wt.path)) { + allWorktrees.push({ path: wt.path, branch: wt.branch ?? "detached" }); + } + } + + return ( +
+ {allWorktrees.map((wt) => ( + + ))} +
+ ); +} + +function SessionList({ sessions }: { sessions: SessionSummary[] }) { + return ( +
+ {sessions.map((session, i) => { + const meta = getSessionModeMeta(session.mode); + const Icon = meta.icon; + return ( + 0 && "border-t border-border", + "text-foreground hover:bg-surface-1", + )} + > + + + {formatSessionLabel(session.label, session.mode)} + + {meta.label} + + ); + })} +
+ ); +} + +function EmptyState({ onAddProject }: { onAddProject: () => void }) { + return ( +
+

No projects yet

+

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

+ +
+ ); +} diff --git a/apps/frontend/src/components/landing/ascii-banner.ts b/apps/frontend/src/components/landing/ascii-banner.ts new file mode 100644 index 000000000..4b096f41f --- /dev/null +++ b/apps/frontend/src/components/landing/ascii-banner.ts @@ -0,0 +1,2 @@ +export const ASCII_BANNER = + " _ __ ,---. .-._ .-._ _,.---._ ,--.--------. ,---. ,--.--------. _,.---._ \n .-`.' ,`. _.-. .--.' \\ /==/ \\ .-._/==/ \\ .-._ ,-.' , - `. /==/, - , -\\.--.' \\ /==/, - , -\\,-.' , - `. .-.,.---. \n /==/, - \\.-,.'| \\==\\-/\\ \\ |==|, \\/ /, /==|, \\/ /, /==/_, , - \\\\==\\.-. - ,-./\\==\\-/\\ \\\\==\\.-. - ,-./==/_, , - \\ /==/ ` \\ \n|==| _ .=. |==|, | /==/-|_\\ | |==|- \\| ||==|- \\| |==| .=. |`--`\\==\\- \\ /==/-|_\\ |`--`\\==\\- \\ |==| .=. |==|-, .=., | \n|==| , '=',|==|- | \\==\\, - \\ |==| , | -||==| , | -|==|_ : ;=: - | \\==\\_ \\ \\==\\, - \\ \\==\\_ \\|==|_ : ;=: - |==| '=' / \n|==|- '..'|==|, | /==/ - ,| |==| - _ ||==| - _ |==| , '=' | |==|- | /==/ - ,| |==|- ||==| , '=' |==|- , .' \n|==|, | |==|- `-._/==/- /\\ - \\|==| /\\ , ||==| /\\ , |\\==\\ - ,_ / |==|, | /==/- /\\ - \\ |==|, | \\==\\ - ,_ /|==|_ . ,'. \n/==/ - | /==/ - , ,|==\\ _.\\=\\.-'/==/, | |- |/==/, | |- | '.='. - .' /==/ -/ \\==\\ _.\\=\\.-' /==/ -/ '.='. - .' /==/ /\\ , ) \n`--`---' `--`-----' `--` `--`./ `--``--`./ `--` `--`--'' `--`--` `--` `--`--` `--`--'' `--`-`--`--' "; diff --git a/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx new file mode 100644 index 000000000..2720756cc --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx @@ -0,0 +1,122 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { GitPullRequest, GitMerge, FileEdit } from "lucide-react"; +import { toast } from "sonner"; +import { daemonApiClient } from "../../../daemon/api/client"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { useGitDashboard } from "./use-git-dashboard"; +import { MetricCards } from "./MetricCards"; +import { PRGroup } from "./PRGroup"; +import { PRRow } from "./PRRow"; + +interface GitDashboardProps { + active: boolean; + onBack: () => void; +} + +export function GitDashboard({ active, onBack }: GitDashboardProps) { + const { groups, metrics, loading, error, isEmpty } = useGitDashboard(active); + const [launchingId, setLaunchingId] = useState(null); + const navigate = useNavigate(); + + const handleSelect = useCallback( + async (pr: GitDashboardPR) => { + setLaunchingId(pr.url); + const result = await daemonApiClient.createReviewSession(pr.projectCwd, pr.url); + setLaunchingId(null); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to start review", { description: result.error.message }); + } + }, + [navigate], + ); + + return ( +
+
+
+ +
+ + {loading && isEmpty && ( +
Loading PRs…
+ )} + + {!loading && isEmpty && ( +
+ {error ?? "No pull requests found across your projects"} +
+ )} + + {!isEmpty && ( +
+
+
+ {groups.open.length > 0 && ( + + {groups.open.map((pr) => ( + + ))} + + )} + {groups.draft.length > 0 && ( + + {groups.draft.map((pr) => ( + + ))} + + )} + {groups.merged.length > 0 && ( + + {groups.merged.map((pr) => ( + + ))} + + )} +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx new file mode 100644 index 000000000..5e605e529 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { DashboardMetrics } from "./use-git-dashboard"; + +interface MetricCardProps { + label: string; + count: number; + active?: boolean; + onClick?: () => void; +} + +function MetricCard({ label, count, active, onClick }: MetricCardProps) { + return ( + + ); +} + +function scrollToGroup(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +export function MetricCards({ metrics }: { metrics: DashboardMetrics }) { + return ( +
+

Pull Requests

+ scrollToGroup("pr-group-open")} + /> + scrollToGroup("pr-group-draft")} + /> + scrollToGroup("pr-group-merged")} + /> +
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx new file mode 100644 index 000000000..42fb6928a --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; + +interface PRGroupProps { + id?: string; + title: string; + icon: LucideIcon; + count: number; + children: React.ReactNode; +} + +export function PRGroup({ id, title, icon: Icon, count, children }: PRGroupProps) { + return ( +
+
+ + {title} + + {count} + +
+
{children}
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx new file mode 100644 index 000000000..2b04046ec --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx @@ -0,0 +1,93 @@ +import { cn } from "@/lib/utils"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { formatRelativeTime } from "./use-git-dashboard"; + +const STATUS_COLORS: Record = { + open: "text-green-500", + merged: "text-purple-500", + closed: "text-red-500", + draft: "text-muted-foreground/50", +}; + +const REVIEW_BADGES: Record = { + APPROVED: { label: "Approved", className: "bg-green-500/10 text-green-600 dark:text-green-400" }, + CHANGES_REQUESTED: { + label: "Changes requested", + className: "bg-red-500/10 text-red-600 dark:text-red-400", + }, + REVIEW_REQUIRED: { + label: "Review required", + className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + }, +}; + +interface PRRowProps { + pr: GitDashboardPR; + loading?: boolean; + onSelect: (pr: GitDashboardPR) => void; +} + +export function PRRow({ pr, loading, onSelect }: PRRowProps) { + const statusKey = pr.isDraft && pr.state === "open" ? "draft" : pr.state; + const reviewBadge = pr.reviewDecision ? (REVIEW_BADGES[pr.reviewDecision] ?? null) : null; + const repoName = pr.repoSlug.split("/")[1] ?? pr.repoSlug; + + return ( + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts new file mode 100644 index 000000000..aec89ee78 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import { useProjectStore } from "../../../stores/project-store"; +import { useGitDashboardStore, type GitDashboardPR } from "../../../stores/git-dashboard-store"; + +export interface PRGroups { + open: GitDashboardPR[]; + draft: GitDashboardPR[]; + merged: GitDashboardPR[]; +} + +export interface DashboardMetrics { + open: number; + draft: number; + merged: number; + total: number; +} + +function groupPRs(prs: GitDashboardPR[]): PRGroups { + const open: GitDashboardPR[] = []; + const draft: GitDashboardPR[] = []; + const merged: GitDashboardPR[] = []; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft.push(pr); + else if (pr.state === "open") open.push(pr); + else if (pr.state === "merged") merged.push(pr); + } + return { open, draft, merged }; +} + +function computeMetrics(prs: GitDashboardPR[]): DashboardMetrics { + let open = 0; + let draft = 0; + let merged = 0; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft++; + else if (pr.state === "open") open++; + else if (pr.state === "merged") merged++; + } + return { open, draft, merged, total: prs.length }; +} + +export function formatRelativeTime(iso: string): string { + if (!iso) return ""; + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +const STALE_MS = 30_000; + +export function useGitDashboard(active = true) { + const projects = useProjectStore((s) => s.projects); + const prs = useGitDashboardStore((s) => s.prs); + const loading = useGitDashboardStore((s) => s.loading); + const error = useGitDashboardStore((s) => s.error); + const lastFetchedAt = useGitDashboardStore((s) => s.lastFetchedAt); + const lastProjectKey = useGitDashboardStore((s) => s.lastProjectKey); + const fetchAllPRs = useGitDashboardStore((s) => s.fetchAllPRs); + + const clear = useGitDashboardStore((s) => s.clear); + + useEffect(() => { + if (!active) return; + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) { + if (prs.length > 0) clear(); + return; + } + const projectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + const stale = + !lastFetchedAt || Date.now() - lastFetchedAt > STALE_MS || projectKey !== lastProjectKey; + if (stale && !loading) fetchAllPRs(projects); + }, [active, projects, prs.length, lastFetchedAt, lastProjectKey, loading, fetchAllPRs, clear]); + + const groups = useMemo(() => groupPRs(prs), [prs]); + const metrics = useMemo(() => computeMetrics(prs), [prs]); + + const isEmpty = + groups.open.length === 0 && groups.draft.length === 0 && groups.merged.length === 0; + + return { groups, metrics, loading, error, isEmpty }; +} diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx new file mode 100644 index 000000000..1aac30548 --- /dev/null +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; +import { ReviewAppEmbedded } from "@plannotator/code-review"; +import { PlanAppEmbedded } from "@plannotator/plan-review"; +import "@plannotator/code-review/styles"; +import "@plannotator/plan-review/styles"; +import type { SessionBootstrap } from "../../daemon/contracts"; +import { appStore } from "../../stores/app-store"; + +const sidebarTrigger = ( + +); + +const openSettings = () => appStore.getState().setSettingsOpen(true); + +interface SessionSurfaceProps { + bootstrap: SessionBootstrap; +} + +export const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { + const { session } = bootstrap; + + if (session.mode === "review") { + return ( + + + + ); + } + + return ( + + + + ); +}); diff --git a/apps/frontend/src/components/settings/AppSettingsDialog.tsx b/apps/frontend/src/components/settings/AppSettingsDialog.tsx new file mode 100644 index 000000000..857ee73c7 --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useAppStore } from "../../stores/app-store"; +import { GeneralTab } from "@plannotator/ui/components/settings/GeneralTab"; +import { PlanGeneralTab } from "@plannotator/ui/components/settings/PlanGeneralTab"; +import { PlanDisplayTab } from "@plannotator/ui/components/settings/PlanDisplayTab"; +import { SavingTab } from "@plannotator/ui/components/settings/SavingTab"; +import { LabelsTab } from "@plannotator/ui/components/settings/LabelsTab"; +import { FilesTab } from "@plannotator/ui/components/settings/FilesTab"; +import { ObsidianTab } from "@plannotator/ui/components/settings/ObsidianTab"; +import { BearTab } from "@plannotator/ui/components/settings/BearTab"; +import { OctarineTab } from "@plannotator/ui/components/settings/OctarineTab"; +import { GitTab, ReviewDisplayTab, CommentsTab } from "@plannotator/ui/components/Settings"; +import { ThemeTab } from "@plannotator/ui/components/ThemeTab"; +import { KeyboardShortcuts } from "@plannotator/ui/components/KeyboardShortcuts"; +import { AISettingsTab } from "@plannotator/ui/components/AISettingsTab"; +import { HooksTab } from "@plannotator/ui/components/settings/HooksTab"; +import { getAIProviderSettings, saveAIProviderSettings } from "@plannotator/ui/utils/aiProvider"; +import { configStore } from "@plannotator/ui/config"; + +interface TabDef { + id: string; + label: string; +} + +const GENERAL_TABS: TabDef[] = [ + { id: "general", label: "General" }, + { id: "theme", label: "Theme" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +const PLAN_TABS: TabDef[] = [ + { id: "plan-general", label: "General" }, + { id: "plan-display", label: "Display" }, + { id: "plan-saving", label: "Saving" }, + { id: "plan-labels", label: "Labels" }, + { id: "plan-hooks", label: "Hooks" }, +]; + +const REVIEW_TABS: TabDef[] = [ + { id: "review-git", label: "Git" }, + { id: "review-display", label: "Display" }, + { id: "review-comments", label: "Comments" }, + { id: "review-ai", label: "AI" }, +]; + +const INTEGRATION_TABS: TabDef[] = [ + { id: "int-files", label: "Files" }, + { id: "int-obsidian", label: "Obsidian" }, + { id: "int-bear", label: "Bear" }, + { id: "int-octarine", label: "Octarine" }, +]; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const [activeTab, setActiveTab] = useState("general"); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setThemePreview(false); + setOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [themePreview, setOpen]); + + // Force re-mount of tab content when dialog opens to ensure fresh state + const [mountKey, setMountKey] = useState(0); + useEffect(() => { + if (open) setMountKey((k) => k + 1); + }, [open]); + + // Detect origin from the active session (if any) + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const activeOrigin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + + // Fetch git user and config from daemon on open + const [gitUser, setGitUser] = useState(); + const [legacyTabMode, setLegacyTabMode] = useState(false); + + useEffect(() => { + if (!open) return; + fetch("/daemon/git/user") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.gitUser) setGitUser(data.gitUser); + }) + .catch(() => {}); + fetch("/daemon/config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.config) { + configStore.init(data.config); + setLegacyTabMode(!!data.config.legacyTabMode); + } + }) + .catch(() => {}); + }, [open]); + + // Daemon-routed fetch for tabs that need server calls without session context + const daemonFetch = useCallback((input: string, init?: RequestInit) => { + const path = + typeof input === "string" && input.startsWith("/api/") ? `/daemon${input.slice(4)}` : input; + return fetch(path, init); + }, []); + + // AI provider state — fetched once when dialog opens + const [aiProviders, setAiProviders] = useState< + Array<{ id: string; name: string; capabilities: Record }> + >([]); + const [aiProviderId, setAiProviderId] = useState( + () => getAIProviderSettings().providerId, + ); + + // Re-read AI provider on each open (could have changed via per-surface settings) + useEffect(() => { + if (open) setAiProviderId(getAIProviderSettings().providerId); + }, [open]); + + useEffect(() => { + if (!open) return; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + if (!apiBase) return; + fetch(`${apiBase}/ai/capabilities`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.providers) setAiProviders(data.providers); + }) + .catch(() => {}); + }, [open, activeSessionId]); + + const handleAiProviderChange = useCallback((providerId: string | null) => { + setAiProviderId(providerId); + const current = getAIProviderSettings(); + saveAIProviderSettings({ ...current, providerId }); + }, []); + + return ( + <> + + + Settings + +
+
+ Settings +
+ v{__APP_VERSION__} + · + + Send feedback + +
+
+ +
+ + General + {GENERAL_TABS.map((tab) => ( + + {tab.label} + + ))} + + Plan Review + {PLAN_TABS.map((tab) => ( + + {tab.label} + + ))} + + Code Review + {REVIEW_TABS.map((tab) => ( + + {tab.label} + + ))} + + Integrations + {INTEGRATION_TABS.map((tab) => ( + + {tab.label} + + ))} + +
+
+ +
+
+ +
+
+ {/* General */} + + { + setLegacyTabMode(enabled); + fetch("/daemon/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ legacyTabMode: enabled }), + }).catch(() => {}); + }} + /> + + + { + setOpen(false); + setThemePreview(true); + }} + /> + + +
+
+
+ Plan Review +
+ +
+
+
+ Code Review +
+ +
+
+
+ + {/* Plan Review */} + + + + + + + + + + + + + + + + + {/* Code Review */} + + + + + + + + + + + + + + {/* Integrations */} + + + + + + + + + + + + +
+
+
+
+
+ + {themePreview && + createPortal( +
+
+
+
+ + Theme Preview + + +
+
+ +
+
+
, + document.body, + )} + + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx new file mode 100644 index 000000000..ec923f135 --- /dev/null +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Moon, Settings, Sun } from "lucide-react"; +import { TaterSpriteSidebar } from "./TaterSpriteSidebar"; +import { appStore } from "../../stores/app-store"; +import { cn } from "@/lib/utils"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { useTheme } from "@plannotator/ui/components/ThemeProvider"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import type { SessionSummary } from "../../daemon/contracts"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; + +const MODE_ORDER = ["plan", "review", "annotate", "goal-setup", "archive"]; + +export function AppSidebarContent() { + const sessions = useDaemonEventStore((s) => s.sessions); + const { resolvedMode, setMode } = useTheme(); + const matchRoute = useMatchRoute(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + const list = map.get(s.mode) ?? []; + list.push(s); + map.set(s.mode, list); + } + return map; + }, [sessions]); + + const toggleTheme = useCallback(() => { + setMode(resolvedMode === "dark" ? "light" : "dark"); + }, [resolvedMode, setMode]); + + return ( + <> + + + +
+ + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + +
+ +
+ + + {MODE_ORDER.map((mode) => { + const modeSessions = grouped.get(mode); + if (!modeSessions?.length) return null; + const meta = getSessionModeMeta(mode); + + const Icon = meta.icon; + return ( + + + + {meta.label}s + + + + {modeSessions.map((session) => { + const isActive = !!matchRoute({ + to: "/s/$sessionId", + params: { sessionId: session.id }, + }); + const isTerminal = + session.status === "completed" || session.status === "cancelled"; + + return ( + + + + + + {formatSessionLabel(session.label, session.mode)} + + + + + ); + })} + + + + ); + })} + + + + + + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings + + + + + {resolvedMode === "dark" ? : } + Toggle theme + + + + + + ); +} + +export function AppSidebar() { + return ( + + + + ); +} diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..705d25dc5 --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const hideTimeout = useRef | null>(null); + + const show = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + hideTimeout.current = setTimeout(() => setVisible(false), 150); + }, []); + + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on left edge */} +
+ {/* Backdrop overlay */} + {backdropMounted && ( +
+ )} + {/* Floating sidebar panel */} +
+
+ +
+
+ + ); +} diff --git a/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx new file mode 100644 index 000000000..2da10b85c --- /dev/null +++ b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx @@ -0,0 +1,32 @@ +import spriteSheet from "../../assets/sprite_package_sidebar/sprite.png"; + +const NATIVE_W = 117; +const NATIVE_H = 96; +const FRAMES = 24; +const DISPLAY_H = 40; +const SCALE = DISPLAY_H / NATIVE_H; +const DISPLAY_W = NATIVE_W * SCALE; +const TOTAL_WIDTH = NATIVE_W * FRAMES * SCALE; + +export function TaterSpriteSidebar() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..acf8c3d9e --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,77 @@ +import { Slot, Slottable } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[color,background-color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + xxs: "h-6 rounded-md gap-1.5 px-2.5", + xs: "h-7 rounded-md gap-1.5 px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonIcon = React.ReactNode; + +function Button({ + children, + className, + variant, + size, + asChild = false, + iconLeft, + iconRight, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + iconLeft?: ButtonIcon; + iconRight?: ButtonIcon; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + {iconLeft ? ( + + ) : null} + {children} + {iconRight ? ( + + ) : null} + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1a98f2f22 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 000000000..13fc29e98 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx new file mode 100644 index 000000000..856296e91 --- /dev/null +++ b/apps/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx new file mode 100644 index 000000000..4873123f8 --- /dev/null +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { X } from "lucide-react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..21ea19364 --- /dev/null +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { PanelLeft } from "lucide-react"; +import { Slot } from "@radix-ui/react-slot"; +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +const SIDEBAR_STORAGE_KEY = "sidebar_state"; +const SIDEBAR_WIDTH = "244px"; // 16rem +const SIDEBAR_WIDTH_MOBILE = "260px"; // 18rem +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +const MOBILE_BREAKPOINT = 1024; + +function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(() => { + if (typeof window === "undefined") { + return defaultOpen; + } + + const storedOpenState = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); + return storedOpenState === null ? defaultOpen : storedOpenState === "true"; + }); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState)); + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + +
+ +
+ +
+

+ 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/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 3fb1540fe..2eee5ec7c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "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:frontend": "bun run scripts/dev-frontend.ts", + "dev:debug-stack": "bun run scripts/dev-debug-stack.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", 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/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx new file mode 100644 index 000000000..413b9ac8f --- /dev/null +++ b/packages/plannotator-code-review/App.tsx @@ -0,0 +1,2606 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +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 { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; +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'; +import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon'; +import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-types'; +import { configStore, useConfigValue } from '@plannotator/ui/config'; +import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; +import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; +import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider'; +import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog'; +import { needsAISetup } from '@plannotator/ui/utils/aiSetup'; +import { DiffTypeSetupDialog } from '@plannotator/ui/components/DiffTypeSetupDialog'; +import { needsDiffTypeSetup } from '@plannotator/ui/utils/diffTypeSetup'; +import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; +import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; +import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft'; +import { useGitAdd } from './hooks/useGitAdd'; +import { generateId } from './utils/generateId'; +import { useAIChat } from './hooks/useAIChat'; +import { toast, Toaster } from 'sonner'; +import { useCodeNav, type CodeNavRequest } from './hooks/useCodeNav'; +import { extractLinesFromPatch } from './utils/patchParser'; +import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks/useReviewSearch'; +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'; +import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; +import { ReviewSidebar } from './components/ReviewSidebar'; +import type { ReviewSidebarTab } from './components/ReviewSidebar'; +import { SparklesIcon } from './components/SparklesIcon'; +import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; +import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; +import { FileTree } from './components/FileTree'; +import { StackedPRLabel } from './components/StackedPRLabel'; +import { PRSelector } from './components/PRSelector'; +import { PRSwitchOverlay } from './components/PRSwitchOverlay'; +import { usePRStack } from './hooks/usePRStack'; +import { usePRSession, type PRSessionUpdate } from './hooks/usePRSession'; +import { useAnnotationFactory } from './hooks/useAnnotationFactory'; +import { DEMO_DIFF } from './demoData'; +import { exportReviewFeedback } from './utils/exportFeedback'; +import { ReviewSubmissionDialog, buildReviewSubmission, type ReviewSubmission, type SubmissionTarget } from './components/ReviewSubmissionDialog'; +import { ReviewStateProvider, type ReviewState } from './dock/ReviewStateContext'; +import { JobLogsProvider } from './dock/JobLogsContext'; +import { reviewPanelComponents } from './dock/reviewPanelComponents'; +import { ReviewDockTabRenderer } from './dock/ReviewDockTabRenderer'; +import { usePRContext } from './hooks/usePRContext'; +import { + REVIEW_PANEL_TYPES, + REVIEW_DIFF_PANEL_ID, + makeReviewAgentJobPanelId, + getReviewDiffPanelFilePath, + isReviewDiffPanelId, + REVIEW_PR_SUMMARY_PANEL_ID, + REVIEW_PR_COMMENTS_PANEL_ID, + REVIEW_PR_CHECKS_PANEL_ID, + REVIEW_ALL_FILES_PANEL_ID, + 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; + +interface DiffData { + files: DiffFile[]; + rawPatch: string; + gitRef: string; + origin?: Origin; + diffType?: string; + gitContext?: GitContext; + sharingEnabled?: boolean; + prStackInfo?: PRStackInfo | null; + prDiffScope?: PRDiffScope; + prDiffScopeOptions?: PRDiffScopeOption[]; +} + +// Simple diff parser to extract files from unified diff +function parseDiffToFiles(rawPatch: string): DiffFile[] { + const files: DiffFile[] = []; + const fileChunks = rawPatch.split(/^diff --git /m).filter(Boolean); + + for (const chunk of fileChunks) { + const lines = chunk.split('\n'); + const headerMatch = lines[0]?.match(/a\/(.+) b\/(.+)/); + if (!headerMatch) continue; + + const oldPath = headerMatch[1]; + const newPath = headerMatch[2]; + + let additions = 0; + let deletions = 0; + + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++; + if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } + + files.push({ + path: newPath, + oldPath: oldPath !== newPath ? oldPath : undefined, + patch: 'diff --git ' + chunk, + additions, + deletions, + }); + } + + return files; +} + +function getFileTabTitle(filePath: string): string { + return filePath.split('/').pop() ?? filePath; +} + +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 isAllFilesActive = useReviewStore(s => s.isAllFilesActive); + const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); + const [allFilesVisibleFile, setAllFilesVisibleFile] = 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'); + const diffOverflow = useConfigValue('diffOverflow'); + const diffIndicators = useConfigValue('diffIndicators'); + const diffLineDiffType = useConfigValue('diffLineDiffType'); + const diffShowLineNumbers = useConfigValue('diffShowLineNumbers'); + const diffShowBackground = useConfigValue('diffShowBackground'); + const diffHideWhitespace = useConfigValue('diffHideWhitespace'); + const diffFontFamily = useConfigValue('diffFontFamily'); + const diffFontSize = useConfigValue('diffFontSize'); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + if (diffFontFamily) { + loadDiffFont(diffFontFamily); + el.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); + } else { + el.style.removeProperty('--diff-font-override'); + } + if (diffFontSize) { + el.style.setProperty('--diff-font-size-override', diffFontSize); + el.classList.add('has-font-size-override'); + } else { + el.style.removeProperty('--diff-font-size-override'); + el.classList.remove('has-font-size-override'); + } + 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); + const [copyFeedback, setCopyFeedback] = useState(null); + const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle'); + 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: + // selectedBase — what the picker is currently showing (UI intent). + // Updates immediately when the user picks, so the chip + // feels responsive. + // committedBase — the base the server last computed the patch against. + // Drives file-content fetches. Only updates after + // /api/diff/switch returns, so we never pair an old + // patch with a new base's file contents (race that + // produced "trailing context mismatch" warnings). + const [selectedBase, setSelectedBase] = useState(null); + const [committedBase, setCommittedBase] = useState(null); + const [agentCwd, setAgentCwd] = useState(null); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [diffError, setDiffError] = useState(null); + const [isSendingFeedback, setIsSendingFeedback] = useState(false); + 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, sessionVisible]); + + const { prMetadata, prStackInfo, prStackTree, prDiffScope, prDiffScopeOptions, updatePRSession } = usePRSession(); + const { withPRContext } = useAnnotationFactory(prMetadata, prStackInfo ? prDiffScope : undefined); + + const prStackCallbacksRef = useRef(null); + const { + isSwitchingPRScope, + handleScopeSelect: handlePRDiffScopeSelect, + handlePRSwitch, + } = usePRStack(prStackCallbacksRef); + const [reviewDestination, setReviewDestination] = useState<'agent' | 'platform'>(() => { + const stored = storage.getItem('plannotator-review-dest'); + return stored === 'agent' ? 'agent' : 'platform'; // 'github' (legacy) → 'platform' + }); + const [showDestinationMenu, setShowDestinationMenu] = useState(false); + const [isPlatformActioning, setIsPlatformActioning] = useState(false); + const [platformActionError, setPlatformActionError] = useState(null); + const [platformUser, setPlatformUser] = useState(null); + const [platformCommentDialog, setPlatformCommentDialog] = useState<{ action: 'approve' | 'comment'; plan: ReviewSubmission } | null>(null); + const [platformGeneralComment, setPlatformGeneralComment] = useState(''); + const [platformOpenPR, setPlatformOpenPR] = useState(() => { + const platformSetting = storage.getItem('plannotator-platform-open-pr'); + if (platformSetting !== null) return platformSetting !== 'false'; + + const legacyGitHubSetting = storage.getItem('plannotator-github-open-pr'); + if (legacyGitHubSetting !== null) { + storage.setItem('plannotator-platform-open-pr', legacyGitHubSetting); + return legacyGitHubSetting !== 'false'; + } + + return true; + }); + + // Derived: Platform mode is active when destination is platform AND we have PR/MR metadata + const platformMode = reviewDestination === 'platform' && !!prMetadata; + + // Platform-aware labels + const platformLabel = prMetadata ? getPlatformLabel(prMetadata) : 'GitHub'; + const mrLabel = prMetadata ? getMRLabel(prMetadata) : 'PR'; + const mrNumberLabel = prMetadata ? getMRNumberLabel(prMetadata) : ''; + const displayRepo = prMetadata ? getDisplayRepo(prMetadata) : ''; + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + + const identity = useConfigValue('displayName'); + + const clearPendingSelection = useCallback(() => { + storeApi.getState().setPendingSelection(null); + }, [storeApi]); + + // VS Code editor annotations (only polls when inside VS Code webview) + const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); + + // 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. + // 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); + + // Dockview center panel API for the review workspace. + const [dockApi, setDockApi] = useState(null); + const filesRef = useRef(files); + 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); + + // Sync activeFileIndex from dockview's active panel (wired in handleDockReady) + + const openDiffFile = useCallback((filePath: string) => { + const file = files.find(candidate => candidate.path === filePath); + if (!file) return; + + if (!dockApi) { + const fileIndex = files.findIndex(candidate => candidate.path === filePath); + if (fileIndex !== -1) { + storeApi.getState().setFocusedFile(fileIndex); + } + return; + } + + const existing = dockApi.getPanel(REVIEW_DIFF_PANEL_ID); + if (existing) { + const existingFilePath = getReviewDiffPanelFilePath(existing.params); + if (existingFilePath === filePath) { + if (dockApi.activePanel?.id !== REVIEW_DIFF_PANEL_ID) { + existing.api.setActive(); + } + const fileIndex = files.findIndex(candidate => candidate.path === filePath); + if (fileIndex !== -1) { + storeApi.getState().setFocusedFile(fileIndex); + } + needsInitialDiffPanel.current = false; + return; + } + + storeApi.getState().setPendingSelection(null); + existing.api.updateParameters({ filePath }); + existing.api.setTitle(getFileTabTitle(file.path)); + existing.api.setActive(); + } else { + storeApi.getState().setPendingSelection(null); + dockApi.addPanel({ + id: REVIEW_DIFF_PANEL_ID, + component: REVIEW_PANEL_TYPES.DIFF, + title: getFileTabTitle(file.path), + params: { filePath }, + }); + } + + storeApi.getState().setFocusedFile(files.findIndex(candidate => candidate.path === filePath)); + needsInitialDiffPanel.current = false; + }, [dockApi, files]); + + const handleRevealSearchMatch = useCallback((match: ReviewSearchMatch) => { + openDiffFile(match.filePath); + }, [openDiffFile]); + + const { + searchQuery, + debouncedSearchQuery, + isSearchPending, + isSearchOpen, + activeSearchMatchId, + activeSearchMatch, + activeFileSearchMatches, + searchMatches, + searchGroups, + searchInputRef, + openSearch, + closeSearch, + clearSearch, + stepSearchMatch, + handleSearchInputChange, + handleSelectSearchMatch, + } = useReviewSearch({ + files, + activeFilePath: files[activeFileIndex]?.path ?? null, + onRevealMatch: handleRevealSearchMatch, + }); + + const hasSearchableFiles = files.length > 0; + const shouldShowFileTree = + hasSearchableFiles || + !!gitContext?.diffOptions?.length || + !!gitContext?.worktrees?.length; + + // 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 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]), + }); + + // AI Chat + const [aiAvailable, setAiAvailable] = useState(false); + const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); + const [aiConfig, setAiConfig] = useState(() => { + const saved = getAIProviderSettings(); + const pid = saved.providerId; + return { + providerId: pid, + model: pid ? (saved.preferredModels[pid] ?? null) : null, + reasoningEffort: null as string | null, + }; + }); + const [showAISetup, setShowAISetup] = useState(false); + const [aiCheckComplete, setAiCheckComplete] = useState(false); + const [showDiffTypeSetup, setShowDiffTypeSetup] = useState(false); + const [diffTypeSetupPending, setDiffTypeSetupPending] = useState(false); + const aiChat = useAIChat({ + patch: diffData?.rawPatch ?? '', + providerId: aiConfig.providerId, + model: aiConfig.model, + reasoningEffort: aiConfig.reasoningEffort, + }); + + const codeNav = useCodeNav(); + + const handleCodeNavRequest = useCallback((request: CodeNavRequest) => { + if (!gitContext && !agentCwd) { + toast('Code navigation requires a local checkout', { + description: 'Re-run with --local for PR reviews', + duration: 4000, + }); + return; + } + codeNav.resolve(request); + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_CODE_NAV_PANEL_ID); + if (existing) { + existing.api.setTitle(`References: ${request.symbol}`); + existing.api.setActive(); + } else { + const refPanel = isAllFilesActive + ? REVIEW_ALL_FILES_PANEL_ID + : REVIEW_DIFF_PANEL_ID; + dockApi.addPanel({ + id: REVIEW_CODE_NAV_PANEL_ID, + component: REVIEW_PANEL_TYPES.CODE_NAV, + title: `References: ${request.symbol}`, + position: { direction: 'below', referencePanel: refPanel }, + initialHeight: 250, + }); + } + }, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]); + + // Check AI capabilities on mount + useEffect(() => { + fetch('/api/ai/capabilities') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.available) { + setAiAvailable(true); + const providers = data.providers ?? []; + setAiProviders(providers); + } + setAiCheckComplete(true); + }) + .catch(() => { setAiCheckComplete(true); }); + }, []); + + const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null }) => { + setAiConfig(prev => { + const next = { ...prev, ...config }; + // If provider changed, load that provider's preferred model + if (config.providerId !== undefined && config.providerId !== prev.providerId) { + next.model = config.providerId ? getPreferredModel(config.providerId) : null; + } + // Persist provider selection + const saved = getAIProviderSettings(); + saveAIProviderSettings({ ...saved, providerId: next.providerId }); + return next; + }); + aiChat.resetSession(); + }, [aiChat]); + + const handleAskAI = useCallback((question: string) => { + 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); + + aiChat.ask({ + prompt: question, + filePath: f[focusedFileIndex].path, + lineStart, + lineEnd, + side, + selectedCode: selectedCode || undefined, + }); + }, [storeApi, aiChat]); + + const handleViewAIResponse = useCallback((questionId?: string) => { + reviewSidebar.open('ai'); + if (questionId) { + setScrollToQuestionId(questionId); + setTimeout(() => setScrollToQuestionId(null), 500); + } + }, []); + + const handleScrollToAILines = useCallback((filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => { + openDiffFile(filePath); + // Set a selection to highlight the lines + storeApi.getState().setPendingSelection({ + start: lineStart, + end: lineEnd, + side: side === 'new' ? 'additions' : 'deletions', + }); + }, [storeApi, openDiffFile]); + + + // AI messages overlapping the current selection (for toolbar history) + const aiHistoryForSelection = useMemo(() => { + if (!pendingSelection || !files[activeFileIndex]) return []; + const filePath = files[activeFileIndex].path; + const selStart = Math.min(pendingSelection.start, pendingSelection.end); + const selEnd = Math.max(pendingSelection.start, pendingSelection.end); + const side = pendingSelection.side === 'additions' ? 'new' : 'old'; + return aiChat.messages.filter(m => { + const q = m.question; + return q.filePath === filePath && q.side === side && + q.lineStart != null && q.lineEnd != null && + q.lineStart <= selEnd && q.lineEnd >= selStart; + }); + }, [pendingSelection, files, activeFileIndex, aiChat.messages]); + + // Click AI marker in diff → scroll sidebar to that Q&A + const [scrollToQuestionId, setScrollToQuestionId] = useState(null); + const handleClickAIMarker = useCallback((questionId: string) => { + setScrollToQuestionId(questionId); + reviewSidebar.open('ai'); + // Clear after a tick so it can re-trigger for the same question + setTimeout(() => setScrollToQuestionId(null), 500); + }, []); + + // General AI question from sidebar input + const handleAskGeneral = useCallback((question: string) => { + aiChat.ask({ prompt: question }); + }, [aiChat.ask]); + + // Resizable panels + const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' }); + const fileTreeResize = useResizablePanel({ + storageKey: 'plannotator-filetree-width', + defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left', + }); + const isResizing = panelResize.isDragging || fileTreeResize.isDragging; + + // Dockview ready handler — stores API and wires active panel tracking. + // Initial panel creation happens in the effect below once dockApi is set. + const handleDockReady = useCallback((event: DockviewReadyEvent) => { + setDockApi(event.api); + + // Sync activeFileIndex when user switches between dock tabs + event.api.onDidActivePanelChange((panel) => { + 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) { + storeApi.getState().setFocusedFile(fileIndex); + } + }); + + // Hide Dockview chrome only for the dedicated single diff tab. + // Any lone non-diff panel still needs a visible header so it can be + // dragged, closed, and used as a way back out of the dock. + const updateHeaders = () => { + const lonePanel = + event.api.totalPanels === 1 && event.api.groups.length === 1 + ? event.api.groups[0]?.panels[0] + : undefined; + const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID || lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID; + for (const group of event.api.groups) { + group.header.hidden = hideHeaders; + } + }; + event.api.onDidAddPanel(updateHeaders); + event.api.onDidRemovePanel(updateHeaders); + event.api.onDidAddGroup(updateHeaders); + event.api.onDidRemoveGroup(updateHeaders); + event.api.onDidMovePanel(updateHeaders); + event.api.onDidLayoutChange(updateHeaders); + updateHeaders(); + }, []); + + // Open agent job detail as center dock panel + const handleOpenJobDetail = useCallback((jobId: string) => { + const api = dockApi; + if (!api) return; + const panelId = makeReviewAgentJobPanelId(jobId); + const existing = api.getPanel(panelId); + if (existing) { + existing.api.setActive(); + return; + } + const job = agentJobs.jobs.find(j => j.id === jobId); + api.addPanel({ + id: panelId, + component: REVIEW_PANEL_TYPES.AGENT_JOB_DETAIL, + title: job?.label ?? `Job ${jobId.slice(0, 8)}`, + params: { jobId }, + }); + }, [dockApi, agentJobs.jobs]); + + // Open tour as a dialog overlay + const handleOpenTour = useCallback((jobId: string) => { + setTourDialogJobId(jobId); + }, []); + + // Dev-only: Cmd/Ctrl+Shift+T toggles the demo tour for fast UI iteration. + 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)); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // Auto-open tour dialog when a tour job completes + const tourAutoOpenRef = useRef(new Set()); + useEffect(() => { + for (const job of agentJobs.jobs) { + if ( + job.provider === 'tour' && + job.status === 'done' && + !tourAutoOpenRef.current.has(job.id) + ) { + tourAutoOpenRef.current.add(job.id); + setTourDialogJobId(job.id); + } + } + }, [agentJobs.jobs]); + + // Open PR panel as center dock panel + const handleOpenPRPanel = useCallback((type: 'summary' | 'comments' | 'checks') => { + const api = dockApi; + if (!api) return; + const config = { + summary: { id: REVIEW_PR_SUMMARY_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_SUMMARY, title: 'PR Summary' }, + comments: { id: REVIEW_PR_COMMENTS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_COMMENTS, title: 'PR Comments' }, + checks: { id: REVIEW_PR_CHECKS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_CHECKS, title: 'PR Checks' }, + }[type]; + const existing = api.getPanel(config.id); + if (existing) { + existing.api.setActive(); + return; + } + api.addPanel({ + id: config.id, + component: config.component, + title: config.title, + }); + }, [dockApi]); + + const openAllFilesPanel = useCallback(() => { + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID); + if (existing) { existing.api.setActive(); return; } + dockApi.addPanel({ + id: REVIEW_ALL_FILES_PANEL_ID, + component: REVIEW_PANEL_TYPES.ALL_FILES, + title: 'All files', + }); + }, [dockApi]); + + // Open the all-files panel on first load. + useEffect(() => { + if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return; + needsInitialDiffPanel.current = false; + openAllFilesPanel(); + }, [dockApi, files, openAllFilesPanel]); + + // 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) { + e.preventDefault(); + setIsFileTreeOpen(true); + openSearch(); + } + return; + } + + // Enter/F3 to step through search matches + if ((e.key === 'Enter' || e.key === 'F3') && searchMatches.length > 0 && !isSearchPending && !isTypingTarget(e.target)) { + e.preventDefault(); + stepSearchMatch(e.shiftKey ? -1 : 1); + return; + } + + // Escape closes modals or clears search + if (e.key === 'Escape') { + if (showDestinationMenu) { + setShowDestinationMenu(false); + } else if (showExportModal) { + setShowExportModal(false); + } else if (isSearchOpen) { + if (searchQuery) { + clearSearch(); + } else { + closeSearch(); + } + } else if (searchQuery) { + clearSearch(); + } + } + // Cmd/Ctrl+B to toggle file tree + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) { + e.preventDefault(); + setIsFileTreeOpen(prev => !prev); + } + // Cmd/Ctrl+. to toggle sidebar + if ((e.metaKey || e.ctrlKey) && e.key === '.' && !isTypingTarget(e.target)) { + e.preventDefault(); + if (reviewSidebar.isOpen) reviewSidebar.close(); + else reviewSidebar.open(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showExportModal, showDestinationMenu, isSearchOpen, searchQuery, searchMatches, isSearchPending, openSearch, stepSearchMatch, clearSearch, closeSearch, hasSearchableFiles, reviewSidebar.isOpen, reviewSidebar.open, reviewSidebar.close, isFileTreeOpen]); + + + // Load diff content - try API first, fall back to demo + useEffect(() => { + fetch('/api/diff') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { + rawPatch: string; + gitRef: string; + origin?: Origin; + diffType?: string; + base?: string; + gitContext?: GitContext; + agentCwd?: string; + sharingEnabled?: boolean; + repoInfo?: { display: string; branch?: string }; + prMetadata?: PRMetadata; + prStackInfo?: PRStackInfo | null; + prStackTree?: PRStackTree | null; + prDiffScope?: PRDiffScope; + prDiffScopeOptions?: PRDiffScopeOption[]; + platformUser?: string; + viewedFiles?: string[]; + error?: string; + isWSL?: boolean; + serverConfig?: { displayName?: string; gitUser?: string }; + lastDecision?: 'approved' | 'feedback' | 'exited' | null; + }) => { + configStore.init(data.serverConfig); + setGitUser(data.serverConfig?.gitUser); + if ((data.serverConfig as { legacyTabMode?: boolean } | undefined)?.legacyTabMode) setLegacyTabMode(true); + const apiFiles = parseDiffToFiles(data.rawPatch); + setDiffData({ + files: apiFiles, + rawPatch: data.rawPatch, + gitRef: data.gitRef, + origin: data.origin, + diffType: data.diffType, + gitContext: data.gitContext, + sharingEnabled: data.sharingEnabled, + }); + storeApi.getState().setFiles(apiFiles); + if (data.origin) setOrigin(data.origin); + if (data.diffType) setDiffType(data.diffType); + if (data.gitContext) { + setGitContext(data.gitContext); + // Prefer the server's active base (survives page refresh / reconnect) + // over the detected default, so the picker rehydrates to what the + // server is actually using. + const initial = data.base || data.gitContext.defaultBranch || data.gitContext.compareTarget?.fallback || null; + setSelectedBase(initial); + setCommittedBase(initial); + } + if (data.agentCwd) setAgentCwd(data.agentCwd); + if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); + if (data.repoInfo) setRepoInfo(data.repoInfo); + updatePRSession({ + ...(data.prMetadata && { prMetadata: data.prMetadata }), + ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), + ...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }), + ...(data.prDiffScope && { prDiffScope: data.prDiffScope }), + ...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }), + }); + if (data.platformUser) setPlatformUser(data.platformUser); + // Initialize viewed files from GitHub's state (set before draft restore so draft takes precedence) + if (data.viewedFiles && data.viewedFiles.length > 0) { + setViewedFiles(new Set(data.viewedFiles)); + } + if (data.error) setDiffError(data.error); + if (data.isWSL) setIsWSL(true); + // Mark diff type setup as pending on first run (local mode only) + 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 + const demoFiles = parseDiffToFiles(DEMO_DIFF); + setDiffData({ + files: demoFiles, + rawPatch: DEMO_DIFF, + gitRef: 'demo', + }); + storeApi.getState().setFiles(demoFiles); + }) + .finally(() => setIsLoading(false)); + }, []); + + // Show diff type setup dialog only after AI setup dialog is dismissed (avoid stacking) + useEffect(() => { + if (diffTypeSetupPending && aiCheckComplete && !showAISetup) { + setDiffTypeSetupPending(false); + setShowDiffTypeSetup(true); + } + }, [diffTypeSetupPending, aiCheckComplete, showAISetup]); + + const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => { + configStore.set('diffStyle', style); + }, []); + + // Handle line selection from diff viewer + const handleLineSelection = useCallback((range: SelectedLineRange | null) => { + storeApi.getState().setPendingSelection(range); + }, [storeApi]); + + const handleAddAnnotationForFile = useCallback(( + filePath: string, + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta + ) => { + 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, + scope: 'line', + filePath, + lineStart, + lineEnd, + side: sel.side === 'additions' ? 'new' : 'old', + text, + suggestedCode, + originalCode, + ...(tokenMeta && { + charStart: tokenMeta.charStart, + charEnd: tokenMeta.charEnd, + tokenText: tokenMeta.tokenText, + }), + createdAt: Date.now(), + author: identity, + conventionalLabel, + decorations, + }; + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + const handleAddAnnotation = useCallback(( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta + ) => { + 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 { files: f, focusedFileIndex } = storeApi.getState(); + const activeFile = f[focusedFileIndex]; + const trimmed = text.trim(); + if (!activeFile || !trimmed) return; + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type: 'comment', + scope: 'file', + filePath: activeFile.path, + lineStart: 1, + lineEnd: 1, + side: 'new', + text: trimmed, + createdAt: Date.now(), + author: identity, + }; + + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + const handleAddFileCommentForFile = useCallback((filePath: string, text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type: 'comment', + scope: 'file', + filePath, + lineStart: 1, + lineEnd: 1, + side: 'new', + text: trimmed, + createdAt: Date.now(), + author: identity, + }; + + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + // Edit annotation + const handleEditAnnotation = useCallback(( + id: string, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel | null, + decorations?: ConventionalDecoration[], + ) => { + const updates: Partial = { + ...(text !== undefined && { text }), + ...(suggestedCode !== undefined && { suggestedCode }), + ...(originalCode !== undefined && { originalCode }), + ...(conventionalLabel !== undefined && { conventionalLabel: conventionalLabel ?? undefined }), + ...(decorations !== undefined && { decorations }), + }; + 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; + } + state.editAnnotation(id, updates); + }, [storeApi, updateExternalAnnotation]); + + const handleDeleteAnnotation = useCallback((id: string) => { + 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 (state.selectedAnnotationId === id) state.selectAnnotation(null); + return; + } + state.deleteAnnotation(id); + }, [storeApi, deleteExternalAnnotation]); + + // Handle identity change - update author on existing annotations + const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { + storeApi.getState().setLocalAnnotations( + storeApi.getState().localAnnotations.map(ann => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann + ), + ); + }, [storeApi]); + + // Switch file in the dedicated center diff panel. + const handleFilePreview = useCallback((index: number) => { + const file = files[index]; + if (!file) return; + openDiffFile(file.path); + }, [files, openDiffFile]); + + // Double-click currently behaves the same as single-click. + const handleFilePinned = useCallback((index: number) => { + const file = files[index]; + if (!file) return; + openDiffFile(file.path); + }, [files, openDiffFile]); + + // Legacy file switch (used by handleSelectAnnotation, diff switch, etc.) + const handleFileSwitch = useCallback((index: number) => { + const file = files[index]; + if (file) { + openDiffFile(file.path); + } + }, [files, openDiffFile]); + + const handleToggleViewed = useCallback((filePath: string) => { + setViewedFiles(prev => { + const next = new Set(prev); + const willBeViewed = !prev.has(filePath); + if (willBeViewed) { + next.add(filePath); + } else { + next.delete(filePath); + } + // Sync viewed state to GitHub (fire and forget — best effort) + // Capture willBeViewed inside the callback to ensure correctness with React batching + if (prMetadata && prMetadata.platform === 'github') { + fetch('/api/pr-viewed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePaths: [filePath], viewed: willBeViewed }), + }).catch(() => { + // Silently ignore — viewed sync is best-effort + }); + } + return next; + }); + }, [prMetadata]); + + // Derive worktree path and base diff type from the composite diffType string + const { activeWorktreePath, activeDiffBase } = useMemo(() => { + if (diffType.startsWith('worktree:')) { + const rest = diffType.slice('worktree:'.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon !== -1) { + const sub = rest.slice(lastColon + 1); + if (['uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) { + return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub }; + } + } + return { activeWorktreePath: rest, activeDiffBase: 'uncommitted' }; + } + return { activeWorktreePath: null, activeDiffBase: diffType }; + }, [diffType]); + + // Git add/staging logic + const handleFileViewedFromStage = useCallback( + (path: string) => setViewedFiles(prev => new Set(prev).add(path)), + [], + ); + const { stagedFiles, stagingFile, canStageFiles: canStageRaw, stageFile, resetStagedFiles, stageError } = useGitAdd({ + activeDiffBase, + onFileViewed: handleFileViewedFromStage, + }); + // Staging is never available in PR review mode — the server rejects it and the UI shouldn't offer it. + const canStageFiles = canStageRaw && !prMetadata; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return; + if (!isDiffPanelActive) return; + const { files: f, focusedFileIndex } = storeApi.getState(); + const filePath = f[focusedFileIndex]?.path; + if (!filePath) return; + + if (e.key === 'v') { + e.preventDefault(); + handleToggleViewed(filePath); + } else if (e.key === 'a' && canStageFiles) { + e.preventDefault(); + stageFile(filePath); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [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[]; 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); + const currentPath = files[activeFileIndex]?.path; + storeApi.getState().setFiles(nextFiles); + if (isPRSwitch) { + storeApi.getState().setFocusedFile(0); + } else { + const preserved = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + storeApi.getState().setFocusedFile(preserved >= 0 ? preserved : 0); + } + storeApi.getState().setPendingSelection(null); + updatePRSession({ + ...(data.prMetadata && { prMetadata: data.prMetadata }), + ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), + ...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }), + ...(data.prDiffScope && { prDiffScope: data.prDiffScope }), + ...(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()); + } + setDiffError(data.error || null); + resetStagedFiles(); + } + + prStackCallbacksRef.current = { + applyPRResponse, + onError: (message) => setDiffError(message), + }; + + // Shared helper: fetch a diff switch and update state. + // Returns true on success, false on failure — callers that optimistically + // updated UI state (e.g. the base picker) can use this to revert. + const fetchDiffSwitch = useCallback(async (fullDiffType: string, baseOverride?: string, options?: { preserveFile?: boolean }): Promise => { + setIsLoadingDiff(true); + try { + const res = await fetch('/api/diff/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + diffType: fullDiffType, + // Server ignores base for modes that don't use it (uncommitted/staged/etc), + // so forwarding unconditionally is safe and keeps the request shape uniform. + ...((baseOverride ?? selectedBase) && { base: baseOverride ?? selectedBase }), + hideWhitespace: diffHideWhitespace, + }), + }); + + if (!res.ok) throw new Error('Failed to switch diff'); + + const data = await res.json() as { + rawPatch: string; + gitRef: string; + diffType: string; + base?: string; + gitContext?: GitContext; + error?: string; + }; + + const nextFiles = parseDiffToFiles(data.rawPatch); + + if (options?.preserveFile) { + // 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); + storeApi.getState().setFiles(nextFiles); + const nextIdx = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + if (nextIdx !== -1) { + storeApi.getState().setFocusedFile(nextIdx); + } else if (nextFiles.length > 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); + storeApi.getState().setFiles(nextFiles); + setDiffType(data.diffType); + if (data.base) { + setSelectedBase(data.base); + setCommittedBase(data.base); + } + // Merge only the per-cwd fields so the sidebar reflects the worktree + // we're now in. Keep the original `worktrees` list (already filtered to + // exclude the server's startup cwd — replacing it with the new context's + // list would duplicate the "Main repo" entry) and `availableBranches` + // (shared across worktrees of the same repo). + // + // IMPORTANT: we deliberately do NOT overwrite `currentBranch`. The + // WorktreePicker's top "launch" row uses it as a label, and that row + // represents the cwd plannotator was launched in — not whichever + // worktree is currently active. Freezing `currentBranch` at its + // initial-load value keeps that label truthful. `defaultBranch` and + // `diffOptions` update because they describe the active diff, which + // other UI (empty-state text, diff-type picker) should see fresh. + if (data.gitContext) { + setGitContext((prev) => { + if (!prev) return data.gitContext!; + return { + ...prev, + defaultBranch: data.gitContext!.defaultBranch, + diffOptions: data.gitContext!.diffOptions, + compareTarget: data.gitContext!.compareTarget, + jjEvologs: data.gitContext!.jjEvologs, + // HEAD differs per worktree, so refresh the commit-baseline picker. + recentCommits: data.gitContext!.recentCommits, + }; + }); + } + storeApi.getState().setFocusedFile(0); + storeApi.getState().setPendingSelection(null); + resetStagedFiles(); + } + setDiffError(data.error || null); + return true; + } catch (err) { + console.error('Failed to switch diff:', err); + setDiffError(err instanceof Error ? err.message : 'Failed to switch diff'); + return false; + } finally { + setIsLoadingDiff(false); + } + }, [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. + // Optimistically updates the picker; reverts if the server-side switch + // fails so the chip doesn't lie about what the viewer is actually showing. + const handleBaseSelect = useCallback( + async (branch: string) => { + if (branch === selectedBase) return; + const previous = selectedBase; + setSelectedBase(branch); + if (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') { + const ok = await fetchDiffSwitch(diffType, branch); + if (!ok) setSelectedBase(previous); + } + }, + [selectedBase, activeDiffBase, diffType, fetchDiffSwitch], + ); + + // Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active + const handleDiffSwitch = useCallback(async (baseDiffType: string) => { + const fullDiffType = activeWorktreePath + ? `worktree:${activeWorktreePath}:${baseDiffType}` + : baseDiffType; + if (fullDiffType === diffType) return; + // For evolog, default to the second entry (previous state of @) so the + // server doesn't fall back to the jj bookmark/trunk revset. + // When leaving evolog, restore the base to the detected compare target + // so other base-dependent modes (jj-line) don't inherit a commit ID. + const enteringEvolog = + baseDiffType === 'jj-evolog' && gitContext?.jjEvologs && gitContext.jjEvologs.length >= 2; + const leavingEvolog = + !enteringEvolog && activeDiffBase === 'jj-evolog' && gitContext?.defaultBranch; + const baseOverride = enteringEvolog + ? gitContext!.jjEvologs![1].commitId + : leavingEvolog + ? gitContext!.defaultBranch + : undefined; + if (baseOverride) setSelectedBase(baseOverride); + await fetchDiffSwitch(fullDiffType, baseOverride); + }, [diffType, activeWorktreePath, fetchDiffSwitch, gitContext]); + + // Switch worktree context (or back to main repo). Preserves the current + // diff mode across the switch — if the reviewer was looking at "PR Diff" + // in the main repo, they should keep looking at "PR Diff" in the target + // worktree rather than being silently snapped back to "Uncommitted". + const handleWorktreeSwitch = useCallback(async (worktreePath: string | null) => { + if (worktreePath === activeWorktreePath) return; + const fullDiffType = worktreePath + ? `worktree:${worktreePath}:${activeDiffBase}` + : activeDiffBase; + await fetchDiffSwitch(fullDiffType); + }, [activeWorktreePath, activeDiffBase, fetchDiffSwitch]); + + // Re-fetch diff when hideWhitespace toggles so the server applies git diff -w. + // Preserves the active file since only whitespace hunks change. + const hideWhitespaceInitialized = useRef(false); + useEffect(() => { + if (!origin || !gitContext) return; + if (!hideWhitespaceInitialized.current) { + hideWhitespaceInitialized.current = true; + return; + } + fetchDiffSwitch(diffType, selectedBase, { preserveFile: true }); + }, [diffHideWhitespace, origin]); // eslint-disable-line react-hooks/exhaustive-deps + + // Select annotation - switches file if needed and scrolls to it + const handleSelectAnnotation = useCallback((id: string | null) => { + if (!id) { + storeApi.getState().selectAnnotation(null); + return; + } + + const state = storeApi.getState(); + const annotation = selectAllAnnotations(state).find(a => a.id === id); + if (!annotation) { + state.selectAnnotation(id); + return; + } + + if (!state.isAllFilesActive) { + const fileIndex = state.files.findIndex(f => f.path === annotation.filePath); + if (fileIndex !== -1) { + handleFileSwitch(fileIndex); + } + } + + 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 + // (what the server actually computed) and activeDiffBase/activeWorktreePath + // (derived from the committed diffType). Skipped in PR mode — the PR header + // already carries the relevant context. + // Declared before reviewStateValue because both reviewStateValue and the + // feedbackMarkdown memo below read it; moving it below either would put it + // in the TDZ when those memos run on first render. + const feedbackDiffContext = useMemo( + () => + prMetadata || !activeDiffBase + ? undefined + : { + mode: activeDiffBase, + base: committedBase ?? undefined, + worktreePath: activeWorktreePath, + }, + [prMetadata, activeDiffBase, committedBase, activeWorktreePath], + ); + + const prReviewScopeLabel = useMemo(() => { + if (!prMetadata || !prStackInfo) return undefined; + if (prDiffScope === 'full-stack') { + return `Diff vs \`${prMetadata.defaultBranch ?? 'default branch'}\``; + } + return `Diff vs \`${prMetadata.baseBranch}\``; + }, [prMetadata, prStackInfo, prDiffScope]); + + // Build ReviewState value for dock panel context + const reviewStateValue = useMemo(() => ({ + files, + focusedFileIndex: activeFileIndex, + focusedFilePath: files[activeFileIndex]?.path ?? null, + diffStyle, + diffOverflow, + diffIndicators, + lineDiffType: diffLineDiffType, + disableLineNumbers: !diffShowLineNumbers, + disableBackground: !diffShowBackground, + fontFamily: diffFontFamily || undefined, + fontSize: diffFontSize || undefined, + // Only propagate base for modes where it affects old/new content. Avoids + // needless file-content re-fetches when switching to uncommitted/staged/etc. + // Uses committedBase (not selectedBase) so file-content queries wait for + // the new patch to arrive before refetching — otherwise the viewer can + // briefly pair an old patch with the new base's content. + reviewBase: + (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') + ? committedBase ?? undefined + : undefined, + activeDiffBase, + feedbackDiffContext, + prReviewScope: prReviewScopeLabel, + prDiffScope, + allAnnotations, + externalAnnotations, + selectedAnnotationId, + pendingSelection, + onLineSelection: handleLineSelection, + onAddAnnotation: handleAddAnnotation, + onAddAnnotationForFile: handleAddAnnotationForFile, + onAddFileComment: handleAddFileComment, + onAddFileCommentForFile: handleAddFileCommentForFile, + onEditAnnotation: handleEditAnnotation, + onSelectAnnotation: handleSelectAnnotation, + onDeleteAnnotation: handleDeleteAnnotation, + viewedFiles, + onToggleViewed: handleToggleViewed, + stagedFiles, + stagingFile, + onStage: stageFile, + canStageFiles, + stageError, + searchQuery: isSearchPending ? '' : debouncedSearchQuery, + isSearchPending, + debouncedSearchQuery, + activeFileSearchMatches, + activeSearchMatchId, + activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null, + aiAvailable, + aiMessages: aiChat.messages, + onAskAI: handleAskAI, + isAILoading: aiChat.isCreatingSession || aiChat.isStreaming, + onViewAIResponse: handleViewAIResponse, + onClickAIMarker: handleClickAIMarker, + aiHistoryForSelection, + agentJobs: agentJobs.jobs, + prMetadata, + prContext, + isPRContextLoading, + prContextError, + fetchPRContext, + platformUser, + openDiffFile, + onAllFilesVisibleFileChange: setAllFilesVisibleFile, + isAllFilesActive, + openTourPanel: handleOpenTour, + onCodeNavRequest: handleCodeNavRequest, + codeNavResult: codeNav.result, + codeNavIsLoading: codeNav.isLoading, + codeNavActiveSymbol: codeNav.activeSymbol, + }), [ + files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, + diffLineDiffType, diffShowLineNumbers, diffShowBackground, + diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, + allAnnotations, externalAnnotations, + selectedAnnotationId, pendingSelection, handleLineSelection, + handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation, + handleSelectAnnotation, handleDeleteAnnotation, viewedFiles, + handleToggleViewed, stagedFiles, stagingFile, stageFile, + canStageFiles, stageError, isSearchPending, debouncedSearchQuery, + activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, + aiAvailable, aiChat.messages, aiChat.isCreatingSession, aiChat.isStreaming, + handleAskAI, handleViewAIResponse, handleClickAIMarker, + aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, + isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, + handleOpenTour, isAllFilesActive, handleAddAnnotationForFile, + handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol, + ]); + + // 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 + const handleCopyDiff = useCallback(async () => { + if (!diffData) return; + try { + await navigator.clipboard.writeText(diffData.rawPatch); + setCopyRawDiffStatus('success'); + setTimeout(() => setCopyRawDiffStatus('idle'), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyRawDiffStatus('error'); + setTimeout(() => setCopyRawDiffStatus('idle'), 2000); + } + }, [diffData]); + + // Copy feedback markdown to clipboard + const handleCopyFeedback = useCallback(async () => { + if (allAnnotations.length === 0) { + setShowNoAnnotationsDialog(true); + return; + } + try { + const feedback = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel); + await navigator.clipboard.writeText(feedback); + setCopyFeedback('Feedback copied!'); + setTimeout(() => setCopyFeedback(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyFeedback('Failed to copy'); + setTimeout(() => setCopyFeedback(null), 2000); + } + }, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel]); + + const feedbackMarkdown = useMemo(() => { + let output = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel); + if (editorAnnotations.length > 0) { + output += exportEditorAnnotations(editorAnnotations); + } + return output; + }, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel, editorAnnotations]); + + const totalAnnotationCount = allAnnotations.length + editorAnnotations.length; + + // Send feedback to OpenCode via API + const handleSendFeedback = useCallback(async () => { + if (totalAnnotationCount === 0) { + setShowNoAnnotationsDialog(true); + return; + } + setIsSendingFeedback(true); + try { + const agentSwitchSettings = getAgentSwitchSettings(); + const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); + + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approved: false, + feedback: feedbackMarkdown, + annotations: allAnnotations, + ...(effectiveAgent && { agentSwitch: effectiveAgent }), + }), + }); + if (res.ok) { + 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'); + } + } catch (err) { + console.error('Failed to send feedback:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsSendingFeedback(false); + } + }, [totalAnnotationCount, feedbackMarkdown, allAnnotations, storeApi]); + + // Exit review session without sending any feedback + const handleExit = useCallback(async () => { + setIsExiting(true); + try { + const res = await fetch('/api/exit', { method: 'POST' }); + if (res.ok) { + setSubmitted('exited'); + } else { + throw new Error('Failed to exit'); + } + } catch (error) { + console.error('Failed to exit review:', error); + setIsExiting(false); + } + }, []); + + // Approve without feedback (LGTM) + const handleApprove = useCallback(async () => { + setIsApproving(true); + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approved: true, + feedback: 'LGTM - no changes requested.', // unused — integrations branch on `approved` flag + annotations: [], + }), + }); + if (res.ok) { + setSubmitted('approved'); + } else { + throw new Error('Failed to send'); + } + } catch (err) { + console.error('Failed to approve:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsApproving(false); + } + }, []); + + // Submit reviews to one or more PRs via /api/pr-action + const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', plan: ReviewSubmission, generalComment?: string) => { + setIsPlatformActioning(true); + setPlatformActionError(null); + + try { + const bodyForTarget = (target: SubmissionTarget) => { + const parts: string[] = []; + if (generalComment) parts.push(generalComment); + parts.push('Review from Plannotator'); + if (target.fileScopedBody) parts.push(target.fileScopedBody); + return parts.join('\n\n'); + }; + + // For approve, only post to the currently viewed PR. + // For comment with no targets but a general comment, create a minimal target. + let targets = plan.targets; + if (action === 'approve' || (targets.length === 0 && generalComment?.trim())) { + const currentTarget = plan.targets.find(t => t.prUrl === prMetadata?.url); + targets = currentTarget ? [currentTarget] : [{ + prUrl: prMetadata?.url ?? '', + prNumber: prMetadata ? (prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid) : 0, + prTitle: prMetadata?.title ?? '', + prRepo: prMetadata ? getDisplayRepo(prMetadata) : '', + fileComments: [], fileScopedBody: '', + fileCount: 0, annotationCount: 0, status: 'pending' as const, + }]; + } + + const openUrls: string[] = []; + const results = await Promise.allSettled( + targets.map(async (target): Promise => { + if (target.status === 'success') return target; + try { + const prRes = await fetch('/api/pr-action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + body: bodyForTarget(target), + fileComments: target.fileComments, + targetPrUrl: target.prUrl || undefined, + }), + }); + const prData = await prRes.json() as { ok?: boolean; prUrl?: string; error?: string }; + if (!prRes.ok || prData.error) { + return { ...target, status: 'failed', error: prData.error ?? 'Failed to submit' }; + } + if (prData.prUrl) openUrls.push(prData.prUrl); + return { ...target, status: 'success' }; + } catch (err) { + return { ...target, status: 'failed', error: err instanceof Error ? err.message : 'Network error' }; + } + }), + ); + const updatedTargets = results.map((r, i) => r.status === 'fulfilled' ? r.value : { ...targets[i], status: 'failed' as const, error: 'Unexpected error' }); + const allOk = updatedTargets.every(t => t.status === 'success'); + + if (!allOk) { + setPlatformCommentDialog(prev => prev ? { + ...prev, + plan: { ...plan, targets: updatedTargets }, + } : null); + return; + } + + setPlatformCommentDialog(null); + setSubmitted(action === 'approve' ? 'approved' : 'feedback'); + + if (platformOpenPR) { + for (const url of openUrls) window.open(url, '_blank'); + } + + const agentSwitchSettings = getAgentSwitchSettings(); + const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); + const prLinks = openUrls.join(', '); + const statusMessage = action === 'approve' + ? `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} approved on ${platformLabel}${prLinks ? ': ' + prLinks : ''}` + : `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} reviewed on ${platformLabel}${prLinks ? ': ' + prLinks : ''}`; + fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + body: JSON.stringify({ + approved: false, + feedback: statusMessage, + annotations: [], + ...(effectiveAgent && { agentSwitch: effectiveAgent }), + }), + }).catch(() => {}); + } catch (err) { + setPlatformActionError(err instanceof Error ? err.message : 'Failed to submit review'); + } finally { + setIsPlatformActioning(false); + } + }, [platformOpenPR, platformLabel, mrLabel, prMetadata]); + + const openPlatformDialog = useCallback((action: 'approve' | 'comment') => { + const diffPaths = new Set(files.map(f => f.path)); + const prMeta = prMetadata ? { + number: prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid, + title: prMetadata.title, + repo: getDisplayRepo(prMetadata), + } : undefined; + const plan = buildReviewSubmission(allAnnotations, editorAnnotations, prMetadata?.url, diffPaths, prMeta); + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action, plan }); + }, [allAnnotations, editorAnnotations, files, prMetadata]); + + // Double-tap Option/Alt to toggle review destination (PR mode only) + useEffect(() => { + if (!prMetadata) return; + let lastAltUp = 0; + 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; + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key !== 'Alt') return; + const now = Date.now(); + if (now - lastAltUp < DOUBLE_TAP_WINDOW) { + setReviewDestination(prev => { + const next = prev === 'platform' ? 'agent' : 'platform'; + storage.setItem('plannotator-review-dest', next); + setPlatformActionError(null); + return next; + }); + lastAltUp = 0; + } else { + lastAltUp = now; + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [prMetadata]); + + // 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 || feedbackSent || isPlatformActioning) return; + const isApproveAction = platformCommentDialog.action === 'approve'; + const hasTargets = platformCommentDialog.plan.targets.length > 0; + const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); + if (!canSubmit) return; + e.preventDefault(); + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + return; + } + + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return; + if (submitted || feedbackSent || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; + if (!origin) return; // Demo mode + + e.preventDefault(); + + if (platformMode) { + // GitHub mode: No annotations → Approve on GitHub, otherwise → Post Review + const isOwnPR = !!platformUser && prMetadata?.author === platformUser; + if (totalAnnotationCount === 0 && !isOwnPR) { + openPlatformDialog('approve'); + } else { + openPlatformDialog('comment'); + } + } else { + // Agent mode: No annotations → Approve, otherwise → Send Feedback + if (totalAnnotationCount === 0) { + handleApprove(); + } else { + handleSendFeedback(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning, + platformCommentDialog, platformGeneralComment, + submitted, feedbackSent, isSendingFeedback, isApproving, isExiting, isPlatformActioning, + origin, platformMode, platformLabel, platformUser, prMetadata, totalAnnotationCount, openPlatformDialog, + handleApprove, handleSendFeedback, handlePlatformAction + ]); + + if (isLoading) { + const skeleton = ( +
+
Loading diff...
+
+ ); + if (__embedded) return skeleton; + return {skeleton}; + } + + 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 && ( + <> + +
+ + )} + {prMetadata ? ( +
+ + + {displayRepo} + + + +
+ + + +
+
+ ) : repoInfo ? ( +
+ {repoInfo.branch && ( + + {repoInfo.branch} + + )} + + + {repoInfo.display} + +
+ ) : ( + Review + )} +
+ +
+ {/* Diff style toggle */} +
+ + +
+ + {origin && !submitted && !feedbackSent ? ( + <> + {/* Destination dropdown (PR mode only) */} + {prMetadata && ( +
+ + {showDestinationMenu && ( + <> +
setShowDestinationMenu(false)} /> +
+ + +
+ + {altKey} + {altKey} + to toggle + +
+
+ + )} +
+ )} + + {/* GitHub error message */} + {platformActionError && ( +
+ {platformActionError} +
+ )} + + {/* Agent mode: Close/SendFeedback flip + Approve */} + {!platformMode ? ( + totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + /> + ) : ( + <> + {/* Platform mode: Close + Post Comments + Approve */} + totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning} + isLoading={isExiting} + /> + openPlatformDialog('comment')} + disabled={isSendingFeedback || isApproving || isPlatformActioning} + isLoading={isSendingFeedback || isPlatformActioning} + label="Post Comments" + shortLabel="Post" + loadingLabel="Posting..." + shortLoadingLabel="Posting..." + title="Post review to platform" + /> +
+ { + if (platformUser && prMetadata?.author === platformUser) return; + openPlatformDialog('approve'); + }} + disabled={ + isSendingFeedback || isApproving || isPlatformActioning || + (!!platformUser && prMetadata?.author === platformUser) + } + isLoading={isApproving} + muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning} + title={ + platformUser && prMetadata?.author === platformUser + ? `You can't approve your own ${mrLabel}` + : "Approve - no changes needed" + } + /> + {platformUser && prMetadata?.author === platformUser && ( +
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. +
+ )} +
+ + )} + + ) : ( + + )} + +
+ + { if (externalOpenSettings) { externalOpenSettings(); return; } setOpenSettingsMenu(true); }} + onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} + appVersion={appVersion} + /> + +
+ + {/* Sidebar tab toggles */} + + {aiAvailable && ( + + )} + {agentJobs.capabilities?.available && ( + + )} +
+
+ + {/* Embedded completion banner — inline, non-blocking */} + {__embedded && !legacyTabMode && ( + + )} + + {/* Main content */} +
+ {shouldShowFileTree && isFileTreeOpen && ( + <> + f.path === allFilesVisibleFile) : undefined} + onSelectFile={handleFilePreview} + onDoubleClickFile={handleFilePinned} + annotations={allAnnotations} + viewedFiles={viewedFiles} + onToggleViewed={handleToggleViewed} + hideViewedFiles={hideViewedFiles} + onToggleHideViewed={() => setHideViewedFiles(prev => !prev)} + enableKeyboardNav={!showExportModal && hasSearchableFiles} + diffOptions={gitContext?.diffOptions} + activeDiffType={activeDiffBase} + onSelectDiff={handleDiffSwitch} + isLoadingDiff={isLoadingDiff} + width={fileTreeResize.width} + worktrees={gitContext?.worktrees} + activeWorktreePath={activeWorktreePath} + onSelectWorktree={handleWorktreeSwitch} + currentBranch={gitContext?.currentBranch} + availableBranches={prMetadata ? undefined : gitContext?.availableBranches} + selectedBase={prMetadata ? undefined : selectedBase ?? undefined} + detectedBase={prMetadata ? undefined : gitContext?.defaultBranch || gitContext?.compareTarget?.fallback} + onSelectBase={prMetadata ? undefined : handleBaseSelect} + compareTarget={gitContext?.compareTarget} + recentCommits={prMetadata ? undefined : gitContext?.recentCommits} + jjEvologs={prMetadata ? undefined : gitContext?.jjEvologs} + detectedEvoBase={prMetadata ? undefined : gitContext?.jjEvologs?.[1]?.commitId} + stagedFiles={stagedFiles} + onCopyRawDiff={handleCopyDiff} + canCopyRawDiff={!!diffData?.rawPatch} + copyRawDiffStatus={copyRawDiffStatus} + searchQuery={hasSearchableFiles ? searchQuery : ''} + isSearchOpen={hasSearchableFiles ? isSearchOpen : false} + isSearchPending={isSearchPending} + searchInputRef={hasSearchableFiles ? searchInputRef : undefined} + onOpenSearch={hasSearchableFiles ? openSearch : undefined} + onSearchChange={hasSearchableFiles ? handleSearchInputChange : undefined} + onSearchClear={hasSearchableFiles ? clearSearch : undefined} + onSearchClose={hasSearchableFiles ? closeSearch : undefined} + searchGroups={hasSearchableFiles ? searchGroups : []} + searchMatches={hasSearchableFiles ? searchMatches : []} + activeSearchMatchId={hasSearchableFiles ? activeSearchMatchId : null} + onSelectSearchMatch={hasSearchableFiles ? handleSelectSearchMatch : undefined} + onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined} + repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)} + /> + + + )} + + {/* Center dock area */} +
+ {files.length > 0 ? ( + + ) : ( +
+
+
+ {diffError ? ( + + + + ) : ( + + + + )} +
+
+ {diffError ? ( + <> +

Failed to load diff

+

{diffError}

+ + ) : ( + <> +

No changes

+

+ {activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`} + {activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."} + {activeDiffBase === 'unstaged' && "No unstaged changes. All changes are staged."} + {activeDiffBase === 'last-commit' && `No changes in the last commit${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'jj-current' && "No changes in the current jj change."} + {activeDiffBase === 'jj-last' && "No changes in the last jj change."} + {activeDiffBase === 'jj-line' && `No changes in your line of work vs ${selectedBase || gitContext?.defaultBranch || '@-'}.`} + {activeDiffBase === 'jj-evolog' && `No changes since evolution ${selectedBase ? selectedBase.slice(0, 8) : 'previous'} — the change looks the same as before.`} + {activeDiffBase === 'jj-all' && "No files at the current jj change."} + {activeDiffBase === 'branch' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'merge-base' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'all' && `No tracked files${activeWorktreePath ? ' in this worktree' : ' in this repository'}.`} +

+ + )} +
+ {gitContext?.diffOptions && gitContext.diffOptions.length > 1 && ( +

+ Try selecting a different view from the dropdown. +

+ )} +
+
+ )} +
+ + {/* Resize Handle + Sidebar */} + {reviewSidebar.isOpen && ( + <> + + + + )} +
+ + {/* Export Modal */} + {showExportModal && ( +
+
+
+

Export Review Feedback

+ +
+
+
+ {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} +
+
+                  {feedbackMarkdown}
+                
+
+
+ +
+
+
+ )} + + {!externalOpenSettings && ( + + )} + + {/* Worktree info dialog */} + {(gitContext?.cwd || agentCwd) && prMetadata && ( + setShowWorktreeDialog(false)} + title="Local Worktree" + wide + message={ +
+

This PR is checked out locally so review agents have full file access.

+
+ Path + +
+

Automatically removed when this review session ends.

+
+ } + variant="info" + /> + )} + + {/* No annotations dialog */} + setShowNoAnnotationsDialog(false)} + title="No Annotations" + message="You haven't made any annotations yet. There's nothing to copy." + variant="info" + /> + + {/* Approve with annotations warning */} + setShowApproveWarning(false)} + onConfirm={() => { + setShowApproveWarning(false); + handleApprove(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you approve.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + handleExit(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you close.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Close Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + {/* AI setup dialog — first-run only */} + { + setShowAISetup(false); + handleAIConfigChange({ providerId }); + }} + /> + + {/* Diff type setup dialog — first-run only */} + {showDiffTypeSetup && ( + { + setShowDiffTypeSetup(false); + if (selected !== diffType) handleDiffSwitch(selected); + }} + /> + )} + + {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} + {(!__embedded || legacyTabMode) && ( + + )} + + {/* Update notification */} + + + {/* GitHub general comment dialog */} + { + setPlatformOpenPR(checked); + storage.setItem('plannotator-platform-open-pr', String(checked)); + }} + onConfirm={() => { + if (!platformCommentDialog) return; + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + }} + onCancel={() => setPlatformCommentDialog(null)} + isSubmitting={isPlatformActioning} + mrLabel={mrLabel} + platformLabel={platformLabel} + /> +
+ + {/* Tour dialog overlay */} + setTourDialogJobId(null)} /> + + {/* Dev-only: open a fully-formed demo tour without running the agent. + Stripped from production builds via import.meta.env.DEV. */} + {import.meta.env.DEV && ( + + )} + + {!__embedded && ( + + )} +
+
+ ); + + if (__embedded) return innerContent; + + return ( + + + {innerContent} + + + ); +}; + +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/plannotator-code-review/components/AIConfigBar.tsx b/packages/plannotator-code-review/components/AIConfigBar.tsx new file mode 100644 index 000000000..d3b2986d7 --- /dev/null +++ b/packages/plannotator-code-review/components/AIConfigBar.tsx @@ -0,0 +1,275 @@ +import type React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProviderInfo { + id: string; + name: string; + models?: AIProviderModel[]; +} + +const REASONING_EFFORTS = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Max' }, +] as const; + +interface AIConfigBarProps { + providers: AIProviderInfo[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange: (effort: string | null) => void; + hasSession: boolean; +} + +export const AIConfigBar: React.FC = ({ + providers, + selectedProviderId, + selectedModel, + selectedReasoningEffort, + onProviderChange, + onModelChange, + onReasoningEffortChange, + hasSession, +}) => { + const [showSessionNote, setShowSessionNote] = useState(false); + const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); + const [modelSearch, setModelSearch] = useState(''); + const barRef = useRef(null); + const searchInputRef = useRef(null); + + // Flash "New chat session" briefly when config changes while a session exists + useEffect(() => { + if (showSessionNote) { + const t = setTimeout(() => setShowSessionNote(false), 2000); + return () => clearTimeout(t); + } + }, [showSessionNote]); + + // Close menu on click outside + useEffect(() => { + if (!openMenu) return; + const handler = (e: MouseEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + setOpenMenu(null); + setModelSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [openMenu]); + + if (providers.length === 0) { + return ( +
+ No AI providers available +
+ ); + } + + const effectiveProviderId = selectedProviderId ?? providers[0]?.id; + const currentProvider = providers.find(p => p.id === effectiveProviderId) ?? providers[0]; + if (!currentProvider) return null; + + const meta = getProviderMeta(currentProvider.name); + const Icon = meta.icon; + const models = currentProvider.models ?? []; + const defaultModel = models.find(m => m.default) ?? models[0]; + const effectiveModel = selectedModel ?? defaultModel?.id; + const currentModelLabel = models.find(m => m.id === effectiveModel)?.label ?? defaultModel?.label; + + const handleProviderSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onProviderChange(id); + setOpenMenu(null); + }; + + const handleModelSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onModelChange(id); + setOpenMenu(null); + setModelSearch(''); + }; + + const handleEffortSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onReasoningEffortChange(id); + setOpenMenu(null); + }; + + const chevron = ( + + + + ); + + return ( +
+ {/* Provider selector */} + {providers.length > 1 ? ( +
+ + + {openMenu === 'provider' && ( +
+ {providers.map(p => { + const m = getProviderMeta(p.name); + const ProvIcon = m.icon; + const isActive = p.id === effectiveProviderId; + return ( + + ); + })} +
+ )} +
+ ) : ( + + + {meta.label} + + )} + + {/* Model selector */} + {models.length > 1 ? ( + <> + · +
+ + + {openMenu === 'model' && ( +
+ {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
+
+ )} +
+ + ) : currentModelLabel ? ( + <> + · + {currentModelLabel} + + ) : null} + + {/* Reasoning effort — Codex only */} + {currentProvider.name === 'codex-sdk' && ( + <> + · +
+ + + {openMenu === 'effort' && ( +
+ {REASONING_EFFORTS.map(e => { + const isActive = e.id === (selectedReasoningEffort ?? 'high'); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* Spacer */} +
+ + {/* Session reset note */} + {showSessionNote && ( + New chat session + )} +
+ ); +}; diff --git a/packages/plannotator-code-review/components/AITab.tsx b/packages/plannotator-code-review/components/AITab.tsx new file mode 100644 index 000000000..4d395e9cd --- /dev/null +++ b/packages/plannotator-code-review/components/AITab.tsx @@ -0,0 +1,398 @@ +import React, { useRef, useEffect, useState, useMemo, useCallback, memo } from 'react'; +import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; +import { renderChatMarkdown } from '../utils/renderChatMarkdown'; +import { formatLineRange } from '../utils/formatLineRange'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; +import { SparklesIcon } from './SparklesIcon'; +import { CountBadge } from './CountBadge'; +import { CopyButton } from './CopyButton'; +import { PermissionCard } from './PermissionCard'; +import { AIConfigBar } from './AIConfigBar'; +import { submitHint } from '@plannotator/ui/utils/platform'; +import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; + +interface AIProviderInfo { + id: string; + name: string; + models?: Array<{ id: string; label: string; default?: boolean }>; +} + +interface AITabProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + activeFilePath?: string; + scrollToQuestionId?: string | null; + onScrollToLines: (filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => void; + onAskGeneral?: (question: string) => void; + permissionRequests?: PendingPermission[]; + onRespondToPermission?: (requestId: string, allow: boolean) => void; + aiProviders?: AIProviderInfo[]; + aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; + onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; + hasAISession?: boolean; +} + +interface FileGroup { + filePath: string; + messages: AIChatEntry[]; +} + +function getQuestionScope(q: AIChatEntry['question']): 'general' | 'file' | 'line' { + if (!q.filePath) return 'general'; + if (q.lineStart == null) return 'file'; + return 'line'; +} + +export const AITab: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + activeFilePath, + scrollToQuestionId, + onScrollToLines, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, + hasAISession = false, +}) => { + const scrollRef = useRef(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [generalInput, setGeneralInput] = useState(''); + const [highlightFilePath, setHighlightFilePath] = useState(null); + + // Group messages by file + const { fileGroups, generalMessages } = useMemo(() => { + const grouped = new Map(); + const general: AIChatEntry[] = []; + + for (const msg of messages) { + if (!msg.question.filePath) { + general.push(msg); + } else { + const existing = grouped.get(msg.question.filePath) || []; + existing.push(msg); + grouped.set(msg.question.filePath, existing); + } + } + + const fileGroups: FileGroup[] = []; + for (const [filePath, msgs] of grouped) { + msgs.sort((a, b) => { + const aScope = getQuestionScope(a.question); + const bScope = getQuestionScope(b.question); + if (aScope !== bScope) return aScope === 'file' ? -1 : 1; + return (a.question.lineStart ?? 0) - (b.question.lineStart ?? 0); + }); + fileGroups.push({ filePath, messages: msgs }); + } + + return { fileGroups, generalMessages: general }; + }, [messages]); + + // Auto-expand active file's group + useEffect(() => { + if (activeFilePath) { + setExpandedFiles(prev => { + if (prev.has(activeFilePath)) return prev; + const next = new Set(prev); + next.add(activeFilePath); + return next; + }); + } + }, [activeFilePath]); + + // Scroll to specific question and flash-highlight its file group header + useEffect(() => { + if (!scrollToQuestionId || !scrollRef.current) return; + + const msg = messages.find(m => m.question.id === scrollToQuestionId); + const filePath = msg?.question.filePath; + + if (filePath) { + const header = scrollRef.current.querySelector(`[data-file-group="${CSS.escape(filePath)}"]`); + if (header) { + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + setHighlightFilePath(filePath); + setTimeout(() => setHighlightFilePath(null), 1200); + } + + if (filePath && expandedFiles.has(filePath)) { + setTimeout(() => { + const el = scrollRef.current?.querySelector(`[data-question-id="${scrollToQuestionId}"]`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + }, [scrollToQuestionId]); + + // Auto-scroll when new messages arrive (not on every streaming token) + const prevMsgCount = useRef(messages.length); + useEffect(() => { + if (!scrollRef.current) return; + const isNewMessage = messages.length > prevMsgCount.current; + prevMsgCount.current = messages.length; + + if (isNewMessage) { + const allQAs = scrollRef.current.querySelectorAll('[data-question-id]'); + const lastQA = allQAs[allQAs.length - 1]; + if (lastQA) { + lastQA.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + } + }, [messages.length]); + + const toggleFile = (filePath: string) => { + setExpandedFiles(prev => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const handleGeneralSubmit = () => { + if (!generalInput.trim() || !onAskGeneral) return; + onAskGeneral(generalInput.trim()); + setGeneralInput(''); + }; + + // Empty state + if (messages.length === 0 && !isCreatingSession) { + return ( +
+
+
+ +
+

+ Select lines and click Ask AI, or ask a general question below. +

+
+ onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + {onAskGeneral && } +
+ ); + } + + return ( +
+ +
+ {isCreatingSession && messages.length === 0 && ( +
+ Starting AI session... +
+ )} + + {/* File-grouped questions */} + {fileGroups.map(({ filePath, messages: fileMessages }) => { + const isExpanded = expandedFiles.has(filePath); + const basename = filePath.split('/').pop() || filePath; + + return ( +
+ + + {isExpanded && ( +
+ {fileMessages.map(({ question, response }) => ( + + ))} +
+ )} +
+ ); + })} + + {/* Pending permission requests */} + {permissionRequests.filter(p => !p.decided).map(perm => ( +
+ {})} + /> +
+ ))} + + {/* General questions */} + {generalMessages.length > 0 && ( +
+ {fileGroups.length > 0 && ( +
+
+ General +
+
+ )} +
+ {generalMessages.map(({ question, response }) => ( + + ))} +
+
+ )} +
+ + + {/* Config bar */} + onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + + {/* General question input */} + {onAskGeneral && } +
+ ); +}; + +/** General question input pinned at bottom — textarea grows upward on multi-line */ +const GeneralInput: React.FC<{ + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled?: boolean; +}> = ({ value, onChange, onSubmit, disabled }) => { + const textareaRef = useRef(null); + + const autoResize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + // Cap at ~6 lines (6 * 16px line-height + padding) + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + useEffect(() => { autoResize(); }, [value, autoResize]); + + return ( +
+
+