diff --git a/AGENTS.md b/AGENTS.md index 96af73406..a570a9ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,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. @@ -234,9 +235,34 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/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 | @@ -260,8 +286,7 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/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 | @@ -286,14 +311,12 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/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 | @@ -309,7 +332,9 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | 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 | @@ -318,8 +343,7 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/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/debug-frontend/.gitignore b/apps/debug-frontend/.gitignore deleted file mode 100644 index aa2168ae1..000000000 --- a/apps/debug-frontend/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.vitest-attachments -src/**/__screenshots__ diff --git a/apps/debug-frontend/.oxlintignore b/apps/debug-frontend/.oxlintignore deleted file mode 100644 index 928e4ae2f..000000000 --- a/apps/debug-frontend/.oxlintignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -src/routeTree.gen.ts diff --git a/apps/debug-frontend/index.html b/apps/debug-frontend/index.html deleted file mode 100644 index a975c0f7a..000000000 --- a/apps/debug-frontend/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Plannotator - - -
- - - diff --git a/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx b/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx deleted file mode 100644 index 7054a077a..000000000 --- a/apps/debug-frontend/src/annotate/AnnotateSessionView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; -import { ApiGroupList } from "../shared/ui/ApiGroupList"; -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { SessionViewComponentProps } from "../sessions/session-view-registry"; - -export function AnnotateSessionView({ bootstrap }: SessionViewComponentProps) { - return ( -
-
-

Annotate

-

{bootstrap.session.label}

-

- Skeleton for markdown, folder, last-message, raw HTML, URL annotation, review-gate - approval, linked docs, image attachments, drafts, and external annotations. -

-
- - -
- ); -} diff --git a/apps/debug-frontend/src/app/layout/ShellLayout.tsx b/apps/debug-frontend/src/app/layout/ShellLayout.tsx deleted file mode 100644 index 1d3d45cac..000000000 --- a/apps/debug-frontend/src/app/layout/ShellLayout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Link, Outlet } from "@tanstack/react-router"; - -export function ShellLayout() { - return ( -
-
-
-

Local runtime shell

-

Plannotator

-
- -
-
- -
-
- ); -} diff --git a/apps/debug-frontend/src/app/state/shell-store.test.ts b/apps/debug-frontend/src/app/state/shell-store.test.ts deleted file mode 100644 index 3f9bcff02..000000000 --- a/apps/debug-frontend/src/app/state/shell-store.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { createShellStore } from "./shell-store"; - -describe("shell store", () => { - test("tracks shell panel and diagnostics state", () => { - const store = createShellStore(); - - store.getState().setActivePanel("diagnostics"); - store.getState().setCompactDensity(true); - store.getState().toggleDiagnostics(); - - expect(store.getState().activePanel).toBe("diagnostics"); - expect(store.getState().compactDensity).toBe(true); - expect(store.getState().diagnosticsOpen).toBe(true); - }); -}); diff --git a/apps/debug-frontend/src/app/state/shell-store.ts b/apps/debug-frontend/src/app/state/shell-store.ts deleted file mode 100644 index a972219fb..000000000 --- a/apps/debug-frontend/src/app/state/shell-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createStore } from "zustand/vanilla"; -import { useStore } from "zustand"; -import { immer } from "zustand/middleware/immer"; - -export type ShellPanel = "sessions" | "details" | "diagnostics"; - -export interface ShellStoreState { - activePanel: ShellPanel; - compactDensity: boolean; - diagnosticsOpen: boolean; -} - -export interface ShellStoreActions { - setActivePanel(panel: ShellPanel): void; - setCompactDensity(value: boolean): void; - toggleDiagnostics(): void; -} - -export type ShellStore = ShellStoreState & ShellStoreActions; - -const initialShellState: ShellStoreState = { - activePanel: "sessions", - compactDensity: false, - diagnosticsOpen: false, -}; - -export function createShellStore(initial: Partial = {}) { - return createStore()( - immer((set) => ({ - ...initialShellState, - ...initial, - setActivePanel(panel) { - set((state) => { - state.activePanel = panel; - }); - }, - setCompactDensity(value) { - set((state) => { - state.compactDensity = value; - }); - }, - toggleDiagnostics() { - set((state) => { - state.diagnosticsOpen = !state.diagnosticsOpen; - }); - }, - })), - ); -} - -export const shellStore = createShellStore(); - -export function useShellStore(selector: (state: ShellStore) => T): T { - return useStore(shellStore, selector); -} diff --git a/apps/debug-frontend/src/archive/ArchiveSessionView.tsx b/apps/debug-frontend/src/archive/ArchiveSessionView.tsx deleted file mode 100644 index 844529277..000000000 --- a/apps/debug-frontend/src/archive/ArchiveSessionView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { apiGroupsForMode } from "../sessions/session-api-groups"; -import { ApiGroupList } from "../shared/ui/ApiGroupList"; -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { SessionViewComponentProps } from "../sessions/session-view-registry"; - -export function ArchiveSessionView({ bootstrap }: SessionViewComponentProps) { - return ( -
-
-

Archive

-

{bootstrap.session.label}

-

- Skeleton for browsing saved plan decisions, inspecting approved and denied plans, and - closing the read-only archive session. -

-
- - -
- ); -} diff --git a/apps/debug-frontend/src/daemon/api/client.test.ts b/apps/debug-frontend/src/daemon/api/client.test.ts deleted file mode 100644 index ab9f25509..000000000 --- a/apps/debug-frontend/src/daemon/api/client.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { createDaemonErrorResponse } from "@plannotator/shared/daemon-protocol"; -import { sessionBootstrap, sessionListFixture } from "../../testing/fixtures/daemon"; -import { createFixtureFetch } from "../../testing/fetch"; -import { createDaemonApiClient } from "./client"; - -describe("daemon API client", () => { - test("loads daemon session bootstrap through the session-scoped API base", async () => { - const bootstrap = sessionBootstrap("plan", 1); - const fixture = createFixtureFetch({ - "/s/plan-session-1/api/session": bootstrap, - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.getSessionBootstrap("plan-session-1"); - - expect(result.ok).toBe(true); - expect(result.ok && result.data.apiBase).toBe("/s/plan-session-1/api"); - expect(fixture.requests).toEqual([{ url: "/s/plan-session-1/api/session", init: undefined }]); - expect(fixture.requests[0].url).not.toBe("/api/session"); - }); - - test("lists sessions from the daemon control plane", async () => { - const fixture = createFixtureFetch({ - "/daemon/sessions?clean=1": sessionListFixture, - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.listSessions({ clean: true }); - - expect(result.ok).toBe(true); - expect(result.ok && result.data.sessions).toHaveLength(5); - }); - - test("builds daemon event and session-scoped API URLs", () => { - const client = createDaemonApiClient({ baseUrl: "http://127.0.0.1:19432/root/" }); - - expect(client.getEventsUrl()).toBe("http://127.0.0.1:19432/daemon/events"); - expect(client.getSessionApiUrl("plan-session_1", "/api/plan")).toBe( - "http://127.0.0.1:19432/s/plan-session_1/api/plan", - ); - }); - - test("runs debug session actions through the session-scoped API", async () => { - const fixture = createFixtureFetch({ - "/s/review-session-1/api/feedback": { ok: true }, - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.runSessionAction( - { - id: "review-session-1", - mode: "review", - status: "active", - url: "http://127.0.0.1/s/review-session-1", - project: "plannotator", - label: "Review", - createdAt: "2026-05-17T00:00:00.000Z", - updatedAt: "2026-05-17T00:00:00.000Z", - }, - "review-approve", - ); - - expect(result.ok).toBe(true); - expect(fixture.requests).toHaveLength(1); - expect(fixture.requests[0].url).toBe("/s/review-session-1/api/feedback"); - expect(fixture.requests[0].init?.method).toBe("POST"); - expect(JSON.parse(String(fixture.requests[0].init?.body))).toMatchObject({ - approved: true, - feedback: "LGTM", - annotations: [], - }); - }); - - test("normalizes daemon error responses", async () => { - const fixture = createFixtureFetch({ - "/s/missing-session/api/session": Response.json( - createDaemonErrorResponse("session-not-found", "Session not found."), - { status: 404 }, - ), - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.getSessionBootstrap("missing-session"); - - expect(result.ok).toBe(false); - expect(!result.ok && result.error.kind).toBe("daemon-error"); - expect(!result.ok && result.error.message).toBe("Session not found."); - }); - - test("normalizes malformed JSON", async () => { - const fixture = createFixtureFetch({ - "/daemon/sessions": new Response("not json", { status: 200 }), - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.listSessions(); - - expect(result.ok).toBe(false); - expect(!result.ok && result.error.kind).toBe("invalid-json"); - }); - - test("normalizes network failures", async () => { - const client = createDaemonApiClient({ - fetch: (async () => { - throw new Error("connection refused"); - }) as typeof fetch, - }); - - const result = await client.listSessions(); - - expect(result.ok).toBe(false); - expect(!result.ok && result.error.kind).toBe("network-error"); - expect(!result.ok && result.error.message).toContain("connection refused"); - }); - - test("rejects malformed success payloads", async () => { - const fixture = createFixtureFetch({ - "/s/plan-session-1/api/session": { ok: true, session: { id: "plan-session-1" } }, - }); - const client = createDaemonApiClient({ fetch: fixture.fetch }); - - const result = await client.getSessionBootstrap("plan-session-1"); - - expect(result.ok).toBe(false); - expect(!result.ok && result.error.kind).toBe("invalid-payload"); - }); -}); diff --git a/apps/debug-frontend/src/daemon/contracts.ts b/apps/debug-frontend/src/daemon/contracts.ts deleted file mode 100644 index 57b1d8b76..000000000 --- a/apps/debug-frontend/src/daemon/contracts.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - DaemonEndpoint, - DaemonErrorResponse, - DaemonEvent, - DaemonSessionBootstrapResponse, - DaemonSessionStatus, - DaemonSessionSummary, - DaemonSessionView, - DaemonStatus, -} from "@plannotator/shared/daemon-protocol"; - -export type ShellSessionView = DaemonSessionView; -export type ShellSessionMode = ShellSessionView | (string & {}); - -export interface ShellSessionSummary extends Omit { - mode: ShellSessionMode; -} - -export interface ShellDaemonStatus extends Omit { - endpoint: DaemonEndpoint; -} - -export interface ShellSessionBootstrap extends Omit { - session: ShellSessionSummary; -} - -export interface ShellSessionListResponse { - ok: true; - sessions: ShellSessionSummary[]; -} - -export interface ShellSessionResponse { - ok: true; - session: ShellSessionSummary; -} - -export interface ShellDeleteSessionResponse { - ok: true; -} - -export type ShellDaemonResponse = T | DaemonErrorResponse; - -export type ShellSessionLifecycleStatus = DaemonSessionStatus; -export type ShellDaemonEvent = - | (Omit, "sessions"> & { - sessions: ShellSessionSummary[]; - }) - | Extract - | Extract - | (Omit< - Extract, - "session" - > & { - session: ShellSessionSummary; - }); diff --git a/apps/debug-frontend/src/daemon/events/event-store.test.ts b/apps/debug-frontend/src/daemon/events/event-store.test.ts deleted file mode 100644 index a5059f107..000000000 --- a/apps/debug-frontend/src/daemon/events/event-store.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { daemonStatusFixture, sessionSummary } from "../../testing/fixtures/daemon"; -import { applyDaemonEvent, createInitialDaemonDebugState } from "./event-store"; - -describe("daemon debug event store reducer", () => { - test("applies snapshot events as the canonical daemon state", () => { - const state = createInitialDaemonDebugState(); - const session = sessionSummary("plan", 1); - - applyDaemonEvent(state, { - type: "snapshot", - at: "2026-05-17T12:00:01.000Z", - status: daemonStatusFixture, - sessions: [session], - }); - - expect(state.status).toBe(daemonStatusFixture); - expect(state.sessions).toEqual([session]); - expect(state.events).toHaveLength(1); - }); - - test("tracks lifecycle updates and removes terminal sessions", () => { - const state = createInitialDaemonDebugState(); - const session = sessionSummary("review", 2); - - applyDaemonEvent(state, { - type: "session-created", - at: "2026-05-17T12:00:01.000Z", - session, - }); - expect(state.sessions).toEqual([session]); - - applyDaemonEvent(state, { - type: "session-updated", - at: "2026-05-17T12:00:02.000Z", - session: { ...session, status: "completed" }, - }); - - expect(state.sessions).toEqual([]); - expect(state.events.map((event) => event.type)).toEqual(["session-updated", "session-created"]); - }); - - test("records daemon errors without dropping prior events", () => { - const state = createInitialDaemonDebugState(); - - applyDaemonEvent(state, { - type: "daemon-error", - at: "2026-05-17T12:00:01.000Z", - code: "internal-error", - message: "session setup failed", - }); - - expect(state.lastError).toBe("session setup failed"); - expect(state.events[0].type).toBe("daemon-error"); - }); -}); diff --git a/apps/debug-frontend/src/daemon/events/event-stream.test.ts b/apps/debug-frontend/src/daemon/events/event-stream.test.ts deleted file mode 100644 index 06e531e1b..000000000 --- a/apps/debug-frontend/src/daemon/events/event-stream.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { daemonStatusFixture, sessionListFixture } from "../../testing/fixtures/daemon"; -import { connectDaemonEvents, parseDaemonEventPayload, type EventSourceLike } from "./event-stream"; - -class FakeEventSource implements EventSourceLike { - onopen: ((event: Event) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - closed = false; - listeners = new Map) => void>(); - - addEventListener(type: string, listener: (event: MessageEvent) => void): void { - this.listeners.set(type, listener); - } - - emit(type: string, payload: unknown): void { - this.listeners.get(type)?.({ data: JSON.stringify(payload) } as MessageEvent); - } - - close(): void { - this.closed = true; - } -} - -describe("daemon event stream client", () => { - test("parses daemon event payloads defensively", () => { - const event = parseDaemonEventPayload( - JSON.stringify({ - type: "daemon-status", - at: "2026-05-17T12:00:00.000Z", - status: daemonStatusFixture, - }), - ); - - expect(event?.type).toBe("daemon-status"); - expect( - parseDaemonEventPayload( - JSON.stringify({ - type: "debug-log", - at: "2026-05-17T12:00:00.000Z", - source: "agent-simulator", - message: "queued claude-plan-hook", - }), - )?.type, - ).toBe("debug-log"); - expect(parseDaemonEventPayload("not json")).toBeNull(); - expect(parseDaemonEventPayload(JSON.stringify({ type: "unknown", at: "now" }))).toBeNull(); - }); - - test("streams typed EventSource events into the frontend state callback", () => { - const events: string[] = []; - const states: string[] = []; - const source = new FakeEventSource(); - - const controller = connectDaemonEvents({ - client: { - getEventsUrl: () => "/daemon/events", - getStatus: async () => ({ ok: true, data: daemonStatusFixture }), - listSessions: async () => ({ ok: true, data: sessionListFixture }), - }, - eventSourceFactory: () => source, - onEvent: (event) => events.push(event.type), - onState: (state) => states.push(state), - onError: (message) => events.push(message), - }); - - source.onopen?.(new Event("open")); - source.emit("snapshot", { - type: "snapshot", - at: "2026-05-17T12:00:00.000Z", - status: daemonStatusFixture, - sessions: sessionListFixture.sessions, - }); - controller.stop(); - - expect(states).toEqual(["connecting", "open"]); - expect(events).toEqual(["snapshot"]); - expect(source.closed).toBe(true); - }); - - test("falls back to polling when EventSource is unavailable", async () => { - const events: string[] = []; - const states: string[] = []; - const intervalHandles: unknown[] = []; - const setIntervalFn = vi.fn((callback: () => void, _intervalMs: number) => { - intervalHandles.push(callback); - return { unref: vi.fn() } as unknown as ReturnType & { - unref?: () => void; - }; - }); - const clearIntervalFn = vi.fn(); - - const controller = connectDaemonEvents({ - client: { - getEventsUrl: () => "/daemon/events", - getStatus: async () => ({ ok: true, data: daemonStatusFixture }), - listSessions: async () => ({ ok: true, data: sessionListFixture }), - }, - eventSourceFactory: undefined, - setIntervalFn, - clearIntervalFn, - onEvent: (event) => events.push(event.type), - onState: (state) => states.push(state), - onError: (message) => events.push(message), - }); - - await Promise.resolve(); - await Promise.resolve(); - controller.stop(); - - expect(states).toEqual(["polling", "polling"]); - expect(events).toContain("snapshot"); - expect(setIntervalFn).toHaveBeenCalledTimes(1); - expect(clearIntervalFn).toHaveBeenCalledTimes(1); - expect(intervalHandles).toHaveLength(1); - }); -}); diff --git a/apps/debug-frontend/src/daemon/events/event-stream.ts b/apps/debug-frontend/src/daemon/events/event-stream.ts deleted file mode 100644 index 28624065c..000000000 --- a/apps/debug-frontend/src/daemon/events/event-stream.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { DaemonApiClient } from "../api/client"; -import type { DaemonApiResult } from "../api/errors"; -import type { ShellDaemonEvent, ShellDaemonStatus, ShellSessionListResponse } from "../contracts"; - -export interface EventSourceLike { - onopen: ((event: Event) => void) | null; - onerror: ((event: Event) => void) | null; - addEventListener(type: string, listener: (event: MessageEvent) => void): void; - close(): void; -} - -type IntervalHandle = ReturnType & { unref?: () => void }; -type SetIntervalLike = (callback: () => void, intervalMs: number) => IntervalHandle; -type ClearIntervalLike = (handle: IntervalHandle) => void; - -export interface DaemonEventStreamOptions { - client: Pick; - onEvent(event: ShellDaemonEvent): void; - onState(state: "connecting" | "open" | "polling" | "error"): void; - onError(message: string): void; - eventSourceFactory?: (url: string) => EventSourceLike; - pollIntervalMs?: number; - setIntervalFn?: SetIntervalLike; - clearIntervalFn?: ClearIntervalLike; -} - -export interface DaemonEventStreamController { - stop(): void; -} - -const DAEMON_EVENT_TYPES = [ - "snapshot", - "daemon-status", - "session-created", - "session-updated", - "session-removed", - "daemon-error", - "debug-log", -] as const; - -export function parseDaemonEventPayload(payload: string): ShellDaemonEvent | null { - try { - const value = JSON.parse(payload) as Partial | null; - if (!value || typeof value !== "object" || typeof value.type !== "string") return null; - if (!DAEMON_EVENT_TYPES.includes(value.type as (typeof DAEMON_EVENT_TYPES)[number])) - return null; - if (typeof value.at !== "string") return null; - return value as ShellDaemonEvent; - } catch { - return null; - } -} - -export function connectDaemonEvents( - options: DaemonEventStreamOptions, -): DaemonEventStreamController { - const pollIntervalMs = options.pollIntervalMs ?? 2_000; - const setIntervalFn: SetIntervalLike = - options.setIntervalFn ?? - ((callback, intervalMs) => setInterval(callback, intervalMs) as IntervalHandle); - const clearIntervalFn: ClearIntervalLike = - options.clearIntervalFn ?? ((handle) => clearInterval(handle)); - let stopped = false; - let pollingTimer: IntervalHandle | undefined; - let eventSource: EventSourceLike | undefined; - - const stopPolling = () => { - if (!pollingTimer) return; - clearIntervalFn(pollingTimer); - pollingTimer = undefined; - }; - - const poll = async () => { - const [statusResult, sessionsResult] = await Promise.all([ - options.client.getStatus(), - options.client.listSessions({ clean: true }), - ]); - if (stopped) return; - emitPollingResult(statusResult, sessionsResult, { - onEvent: options.onEvent, - onError: options.onError, - onState: options.onState, - }); - }; - - const startPolling = () => { - if (stopped || pollingTimer) return; - options.onState("polling"); - void poll(); - pollingTimer = setIntervalFn(() => void poll(), pollIntervalMs); - pollingTimer.unref?.(); - }; - - const factory = options.eventSourceFactory ?? defaultEventSourceFactory(); - if (!factory) { - startPolling(); - return { stop }; - } - - options.onState("connecting"); - eventSource = factory(options.client.getEventsUrl()); - eventSource.onopen = () => { - if (!stopped) options.onState("open"); - }; - eventSource.onerror = () => { - if (stopped) return; - options.onError("Daemon event stream disconnected; falling back to polling."); - options.onState("error"); - eventSource?.close(); - eventSource = undefined; - startPolling(); - }; - - for (const eventType of DAEMON_EVENT_TYPES) { - eventSource.addEventListener(eventType, (event) => { - const parsed = parseDaemonEventPayload(event.data); - if (parsed && !stopped) options.onEvent(parsed); - }); - } - - return { stop }; - - function stop() { - stopped = true; - stopPolling(); - eventSource?.close(); - eventSource = undefined; - } -} - -function defaultEventSourceFactory(): ((url: string) => EventSourceLike) | undefined { - if (typeof EventSource === "undefined") return undefined; - return (url) => new EventSource(url); -} - -function emitPollingResult( - statusResult: DaemonApiResult, - sessionsResult: DaemonApiResult, - options: Pick, -): void { - const at = new Date().toISOString(); - if (!statusResult.ok) { - options.onError(statusResult.error.message); - return; - } - - options.onState("polling"); - - if (!sessionsResult.ok) { - options.onError(sessionsResult.error.message); - options.onEvent({ type: "daemon-status", at, status: statusResult.data }); - return; - } - - options.onEvent({ - type: "snapshot", - at, - status: statusResult.data, - sessions: sessionsResult.data.sessions, - }); -} diff --git a/apps/debug-frontend/src/daemon/events/use-daemon-events.ts b/apps/debug-frontend/src/daemon/events/use-daemon-events.ts deleted file mode 100644 index f08705133..000000000 --- a/apps/debug-frontend/src/daemon/events/use-daemon-events.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from "react"; -import { daemonApiClient, type DaemonApiClient } from "../api/client"; -import { connectDaemonEvents } from "./event-stream"; -import { useDaemonDebugStore } from "./event-store"; - -export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true): void { - const applyEvent = useDaemonDebugStore((state) => state.applyEvent); - const setConnectionState = useDaemonDebugStore((state) => state.setConnectionState); - const setError = useDaemonDebugStore((state) => state.setError); - - useEffect(() => { - if (!enabled) return undefined; - const controller = connectDaemonEvents({ - client, - onEvent: applyEvent, - onState: setConnectionState, - onError: setError, - }); - - return () => controller.stop(); - }, [applyEvent, client, enabled, setConnectionState, setError]); -} diff --git a/apps/debug-frontend/src/debug/EventLog.tsx b/apps/debug-frontend/src/debug/EventLog.tsx deleted file mode 100644 index 7899dbd3a..000000000 --- a/apps/debug-frontend/src/debug/EventLog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { ShellDaemonEvent } from "../daemon/contracts"; -import { useDaemonDebugStore } from "../daemon/events/event-store"; - -export function EventLog() { - const events = useDaemonDebugStore((state) => state.events); - const connectionState = useDaemonDebugStore((state) => state.connectionState); - - if (events.length === 0 && connectionState === "idle") return null; - - return ( -
-
-

Event log

- {connectionState} -
- {events.length === 0 ? ( -

Waiting for events...

- ) : ( -
    - {events.slice(0, 30).map((event, index) => ( - - ))} -
- )} -
- ); -} - -function EventRow({ event }: { event: ShellDaemonEvent }) { - const time = formatEventTime(event.at); - - if (event.type === "debug-log") { - return ( -
  • - - {event.source} - {event.message} -
  • - ); - } - - if (event.type === "session-created" || event.type === "session-updated") { - return ( -
  • - - {event.type} - {event.session.id} -
  • - ); - } - - if (event.type === "daemon-status" || event.type === "snapshot") { - return ( -
  • - - {event.type} - - {event.status.activeSessionCount} active, pid {event.status.pid} - -
  • - ); - } - - return ( -
  • - - {event.type} -
  • - ); -} - -function formatEventTime(isoString: string): string { - const date = new Date(isoString); - if (Number.isNaN(date.getTime())) return isoString; - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 2, - }); -} diff --git a/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx b/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx deleted file mode 100644 index 61c976f07..000000000 --- a/apps/debug-frontend/src/debug/SessionDebugPanel.browser.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { act } from "react"; -import { afterEach, describe, expect, test } from "vitest"; -import type { DaemonApiClient } from "../daemon/api/client"; -import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; -import { sessionBootstraps } from "../testing/fixtures/daemon"; -import { SessionDebugPanel } from "./SessionDebugPanel"; - -let cleanup: (() => Promise) | undefined; - -afterEach(async () => { - await cleanup?.(); - cleanup = undefined; -}); - -function wrapWithRouter(ui: React.ReactNode) { - const rootRoute = createRootRoute({ component: () => ui }); - const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/s/test-session"] }), - }); - return ; -} - -describe("SessionDebugPanel browser rendering", () => { - test("runs API probes and mode-specific action buttons", async () => { - const calls: string[] = []; - const client: DaemonApiClient = { - getStatus: async () => { - throw new Error("not used"); - }, - listSessions: async () => { - throw new Error("not used"); - }, - getSession: async () => { - throw new Error("not used"); - }, - getSessionBootstrap: async () => { - throw new Error("not used"); - }, - cancelSession: async () => { - throw new Error("not used"); - }, - deleteSession: async () => { - throw new Error("not used"); - }, - getEventsUrl: () => "/daemon/events", - getSessionApiUrl: (sessionId, path) => `/s/${sessionId}${path}`, - probeSessionApi: async (_sessionId, path) => { - calls.push(`probe:${path}`); - return { ok: true, data: { ok: true, path } }; - }, - runSessionAction: async (_session, action) => { - calls.push(`action:${action}`); - return { ok: true, data: { ok: true, action } }; - }, - }; - - const rendered = await renderBrowser( - wrapWithRouter(), - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - const buttons = Array.from(rendered.container.querySelectorAll("button")); - await act(async () => { - buttons.find((button) => button.textContent?.includes("/api/diff"))?.click(); - }); - await act(async () => { - buttons.find((button) => button.textContent === "LGTM")?.click(); - }); - - expect(calls).toEqual(["probe:/api/diff", "action:review-approve"]); - }); -}); diff --git a/apps/debug-frontend/src/debug/SessionDebugPanel.tsx b/apps/debug-frontend/src/debug/SessionDebugPanel.tsx deleted file mode 100644 index 188feb018..000000000 --- a/apps/debug-frontend/src/debug/SessionDebugPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; -import { - daemonApiClient, - type DaemonApiClient, - type ShellSessionAction, -} from "../daemon/api/client"; -import type { ShellSessionBootstrap, ShellSessionSummary } from "../daemon/contracts"; - -interface SessionDebugPanelProps { - bootstrap: ShellSessionBootstrap; - client?: DaemonApiClient; -} - -type ActionRole = "approve" | "deny" | "secondary"; - -interface SessionActionDef { - action: ShellSessionAction; - label: string; - role: ActionRole; -} - -const SESSION_ACTIONS: Partial> = { - plan: [ - { action: "plan-approve", label: "Approve", role: "approve" }, - { action: "plan-deny", label: "Deny", role: "deny" }, - ], - review: [ - { action: "review-approve", label: "LGTM", role: "approve" }, - { action: "review-feedback", label: "Feedback", role: "secondary" }, - { action: "review-exit", label: "Exit", role: "secondary" }, - ], - annotate: [ - { action: "annotate-approve", label: "Approve", role: "approve" }, - { action: "annotate-feedback", label: "Feedback", role: "secondary" }, - { action: "annotate-exit", label: "Exit", role: "secondary" }, - ], - archive: [{ action: "archive-done", label: "Close", role: "secondary" }], - "setup-goal": [ - { action: "goal-setup-submit", label: "Submit", role: "approve" }, - { action: "goal-setup-exit", label: "Exit", role: "deny" }, - ], -}; - -export function SessionDebugPanel({ bootstrap, client = daemonApiClient }: SessionDebugPanelProps) { - const navigate = useNavigate(); - const [busyLabel, setBusyLabel] = useState(null); - const [lastResult, setLastResult] = useState<{ label: string; payload: unknown } | null>(null); - const probePath = useMemo(() => probePathForSession(bootstrap.session), [bootstrap.session]); - const actions = SESSION_ACTIONS[bootstrap.session.mode] ?? []; - - const runProbe = async () => { - setBusyLabel("Probe"); - try { - const response = await client.probeSessionApi(bootstrap.session.id, probePath); - setLastResult({ label: `GET ${probePath}`, payload: response }); - } catch (err) { - setLastResult({ label: `GET ${probePath}`, payload: formatError(err) }); - } finally { - setBusyLabel(null); - } - }; - - const runAction = async (action: ShellSessionAction, label: string) => { - setBusyLabel(label); - try { - await client.runSessionAction(bootstrap.session, action); - void navigate({ to: "/" }); - } catch (err) { - setLastResult({ label, payload: formatError(err) }); - setBusyLabel(null); - } - }; - - return ( -
    -
    - {actions.map(({ action, label, role }) => ( - - ))} -
    -
    - -
    - - {lastResult ? ( -
    - {lastResult.label} -
    {JSON.stringify(lastResult.payload, null, 2)}
    -
    - ) : null} - -
    - Session bootstrap -
    {JSON.stringify(bootstrap, null, 2)}
    -
    -
    - ); -} - -function probePathForSession(session: ShellSessionSummary): string { - if (session.mode === "review") return "/api/diff"; - return "/api/plan"; -} - -function formatError(err: unknown): { ok: false; message: string } { - return { - ok: false, - message: err instanceof Error ? err.message : "Unexpected error.", - }; -} diff --git a/apps/debug-frontend/src/plan/PlanSessionView.tsx b/apps/debug-frontend/src/plan/PlanSessionView.tsx deleted file mode 100644 index 65de5e4cd..000000000 --- a/apps/debug-frontend/src/plan/PlanSessionView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; -import { ApiGroupList } from "../shared/ui/ApiGroupList"; -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { SessionViewComponentProps } from "../sessions/session-view-registry"; - -export function PlanSessionView({ bootstrap }: SessionViewComponentProps) { - return ( -
    -
    -

    Plan review

    -

    {bootstrap.session.label}

    -

    - Skeleton for plan approval, denial, annotations, version history, archive sidebar, linked - docs, image attachments, and note export. -

    -
    - - -
    - ); -} diff --git a/apps/debug-frontend/src/review/ReviewSessionView.tsx b/apps/debug-frontend/src/review/ReviewSessionView.tsx deleted file mode 100644 index ddfcef144..000000000 --- a/apps/debug-frontend/src/review/ReviewSessionView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups"; -import { ApiGroupList } from "../shared/ui/ApiGroupList"; -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { SessionViewComponentProps } from "../sessions/session-view-registry"; - -export function ReviewSessionView({ bootstrap }: SessionViewComponentProps) { - return ( -
    -
    -

    Code review

    -

    {bootstrap.session.label}

    -

    - Skeleton for diff browsing, PR switching, AI chat, agent jobs, code navigation, staging, - platform actions, drafts, and review feedback. -

    -
    - - -
    - ); -} diff --git a/apps/debug-frontend/src/routes/index.tsx b/apps/debug-frontend/src/routes/index.tsx deleted file mode 100644 index 111fbf126..000000000 --- a/apps/debug-frontend/src/routes/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SessionDashboard } from "../sessions/SessionDashboard"; - -export const Route = createFileRoute("/")({ - loader: ({ context }) => context.daemonClient.listSessions({ clean: true }), - component: DashboardRoute, -}); - -function DashboardRoute() { - const result = Route.useLoaderData(); - return ; -} diff --git a/apps/debug-frontend/src/routes/s.$sessionId.tsx b/apps/debug-frontend/src/routes/s.$sessionId.tsx deleted file mode 100644 index 25709b552..000000000 --- a/apps/debug-frontend/src/routes/s.$sessionId.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { parseSessionId } from "../sessions/session-id"; -import { SessionRouteView } from "../sessions/SessionRouteView"; - -export const Route = createFileRoute("/s/$sessionId")({ - params: { - parse: ({ sessionId }) => { - const parsed = parseSessionId(sessionId); - return parsed === false ? false : { sessionId: parsed }; - }, - stringify: ({ sessionId }) => ({ sessionId }), - }, - loader: ({ context, params }) => context.daemonClient.getSessionBootstrap(params.sessionId), - component: SessionRoute, -}); - -function SessionRoute() { - const result = Route.useLoaderData(); - return ; -} diff --git a/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx b/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx deleted file mode 100644 index 13870eb74..000000000 --- a/apps/debug-frontend/src/sessions/SessionDashboard.browser.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, test } from "vitest"; -import "../styles.css"; -import { useDaemonDebugStore } from "../daemon/events/event-store"; -import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; -import { emptySessionListFixture, sessionListFixture } from "../testing/fixtures/daemon"; -import { SessionDashboard } from "./SessionDashboard"; - -let cleanup: (() => Promise) | undefined; - -afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - useDaemonDebugStore.getState().reset(); -}); - -describe("SessionDashboard browser rendering", () => { - test("renders the empty state", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain("No active sessions"); - }); - - test("renders session cards", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain("Runtime frontend shell plan"); - expect(rendered.container.textContent).toContain("PR #734 daemon runtime review"); - expect(rendered.container.querySelectorAll(".session-card")).toHaveLength(5); - }); - - test("session IDs are selectable text", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - const sessionId = rendered.container.querySelector(".session-card-id"); - expect(sessionId).toBeTruthy(); - expect(getComputedStyle(sessionId as Element).userSelect).not.toBe("none"); - }); - - test("renders backend errors", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain("Daemon unavailable"); - expect(rendered.container.textContent).toContain("daemon offline"); - }); -}); diff --git a/apps/debug-frontend/src/sessions/SessionDashboard.tsx b/apps/debug-frontend/src/sessions/SessionDashboard.tsx deleted file mode 100644 index 23f342ebe..000000000 --- a/apps/debug-frontend/src/sessions/SessionDashboard.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect } from "react"; -import type { DaemonApiResult } from "../daemon/api/errors"; -import { EventLog } from "../debug/EventLog"; -import { useDaemonDebugStore } from "../daemon/events/event-store"; -import { useDaemonEvents } from "../daemon/events/use-daemon-events"; -import { ResultNotice } from "../shared/ui/ResultNotice"; -import type { ShellSessionListResponse, ShellSessionSummary } from "./types"; - -interface SessionDashboardProps { - result: DaemonApiResult; - debugStream?: boolean; -} - -export function SessionDashboard({ result, debugStream = true }: SessionDashboardProps) { - const replaceSessions = useDaemonDebugStore((state) => state.replaceSessions); - const liveSessions = useDaemonDebugStore((state) => state.sessions); - const status = useDaemonDebugStore((state) => state.status); - - useDaemonEvents(undefined, debugStream); - - useEffect(() => { - if (result.ok) { - replaceSessions(result.data.sessions); - } - }, [result, replaceSessions]); - - if (!result.ok && liveSessions.length === 0) { - return ( - <> - - - - ); - } - - const sessions = liveSessions; - - return ( - <> -
    -
    -

    Sessions

    - {status ? ( -

    - pid {status.pid} · {status.endpoint.baseUrl} -

    - ) : null} -
    -
    - - {sessions.length === 0 ? ( -
    -

    No active sessions. Start a plan, review, or annotate flow to see it here.

    -
    - ) : ( - - )} - - - - ); -} - -function SessionList({ sessions }: { sessions: ShellSessionSummary[] }) { - return ( -
      - {sessions.map((session) => ( -
    • -
      - {session.mode} - {session.project} - {formatTime(session.updatedAt)} -
      - {session.label} - {session.id} - - Open session - -
    • - ))} -
    - ); -} - -function formatTime(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} diff --git a/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx b/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx deleted file mode 100644 index 5d5824586..000000000 --- a/apps/debug-frontend/src/sessions/SessionRouteView.browser.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { cleanupBrowser, renderBrowser } from "../testing/browser/render"; -import { sessionBootstraps } from "../testing/fixtures/daemon"; -import { SessionRouteView } from "./SessionRouteView"; - -let cleanup: (() => Promise) | undefined; - -afterEach(async () => { - await cleanup?.(); - cleanup = undefined; -}); - -describe("SessionRouteView browser rendering", () => { - test.each([ - ["plan", "Plan review"], - ["review", "Code review"], - ["annotate", "Annotate"], - ["archive", "Archive"], - ["setup-goal", "Setup goal"], - ] as const)("renders %s session shell", async (mode, heading) => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain(heading); - expect(rendered.container.textContent).toContain(sessionBootstraps[mode].apiBase); - }); - - test("renders unsupported sessions deliberately", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain("Unsupported session"); - expect(rendered.container.textContent).toContain("unknown-mode"); - }); - - test("renders bootstrap failure", async () => { - const rendered = await renderBrowser( - , - ); - cleanup = () => cleanupBrowser(rendered.root, rendered.container); - - expect(rendered.container.textContent).toContain("Session could not be loaded"); - expect(rendered.container.textContent).toContain("Session not found."); - }); -}); diff --git a/apps/debug-frontend/src/sessions/SessionRouteView.tsx b/apps/debug-frontend/src/sessions/SessionRouteView.tsx deleted file mode 100644 index bd7a927da..000000000 --- a/apps/debug-frontend/src/sessions/SessionRouteView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { DaemonApiResult } from "../daemon/api/errors"; -import { SessionDebugPanel } from "../debug/SessionDebugPanel"; -import { ResultNotice } from "../shared/ui/ResultNotice"; -import type { ShellSessionBootstrap } from "./types"; -import { getSessionViewDefinition } from "./session-view-registry"; -import { UnsupportedSessionView } from "./UnsupportedSessionView"; - -interface SessionRouteViewProps { - result: DaemonApiResult; -} - -export function SessionRouteView({ result }: SessionRouteViewProps) { - if (!result.ok) { - return ; - } - - const definition = getSessionViewDefinition(result.data.session.mode); - if (!definition) { - return ( - <> - - - - ); - } - - const Component = definition.component; - return ( - <> - - - - ); -} diff --git a/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx b/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx deleted file mode 100644 index 23a4f8e28..000000000 --- a/apps/debug-frontend/src/sessions/UnsupportedSessionView.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { ShellSessionBootstrap } from "./types"; - -interface UnsupportedSessionViewProps { - bootstrap: ShellSessionBootstrap; -} - -export function UnsupportedSessionView({ bootstrap }: UnsupportedSessionViewProps) { - return ( -
    -
    -

    Unsupported session

    -

    {bootstrap.session.label}

    -

    - This shell received mode {bootstrap.session.mode}. The route and bootstrap - contract are working, but no product view owns this mode yet. -

    -
    - -
    - ); -} diff --git a/apps/debug-frontend/src/sessions/session-api-groups.ts b/apps/debug-frontend/src/sessions/session-api-groups.ts deleted file mode 100644 index 7a0087ba3..000000000 --- a/apps/debug-frontend/src/sessions/session-api-groups.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { ShellSessionMode } from "./types"; - -export type SessionApiGroupStatus = "ready" | "planned"; - -export interface SessionApiEndpoint { - method: string; - path: string; -} - -export interface SessionApiGroup { - id: string; - title: string; - status: SessionApiGroupStatus; - reason?: string; - endpoints: SessionApiEndpoint[]; -} - -export const sessionApiGroups: Record = { - plan: [ - endpointGroup("plan-bootstrap", "Plan bootstrap and decision", [ - ["GET", "/api/plan"], - ["POST", "/api/approve"], - ["POST", "/api/deny"], - ]), - endpointGroup("plan-history", "Plan version history and diff", [ - ["GET", "/api/plan/version"], - ["GET", "/api/plan/versions"], - ["POST", "/api/plan/vscode-diff"], - ]), - endpointGroup("plan-archive-sidebar", "Archive browsing from plan mode", [ - ["GET", "/api/archive/plans"], - ["GET", "/api/archive/plan"], - ]), - ], - review: [ - endpointGroup("review-diff", "Diff bootstrap and switching", [ - ["GET", "/api/diff"], - ["POST", "/api/diff/switch"], - ["GET", "/api/file-content"], - ]), - endpointGroup("review-pr", "Pull request controls", [ - ["GET", "/api/pr-list"], - ["POST", "/api/pr-switch"], - ["POST", "/api/pr-diff-scope"], - ["GET", "/api/pr-context"], - ["POST", "/api/pr-action"], - ["POST", "/api/pr-viewed"], - ]), - endpointGroup("review-ai", "AI sessions and permissions", [ - ["GET", "/api/ai/capabilities"], - ["POST", "/api/ai/session"], - ["POST", "/api/ai/query"], - ["POST", "/api/ai/abort"], - ["POST", "/api/ai/permission"], - ["GET", "/api/ai/sessions"], - ]), - endpointGroup("review-code-nav", "Code navigation and staging", [ - ["POST", "/api/code-nav/resolve"], - ["GET", "/api/code-nav/file"], - ["POST", "/api/git-add"], - ]), - endpointGroup("review-tour", "Code tour result state", [ - ["GET", "/api/tour/:jobId"], - ["PUT", "/api/tour/:jobId/checklist"], - ]), - endpointGroup("review-submit", "Review feedback and exit", [ - ["POST", "/api/feedback"], - ["POST", "/api/exit"], - ]), - ], - annotate: [ - endpointGroup("annotate-bootstrap", "Annotate content bootstrap", [ - ["GET", "/api/plan"], - ["POST", "/api/feedback"], - ["POST", "/api/approve"], - ["POST", "/api/exit"], - ]), - endpointGroup("annotate-source", "Linked source browsing", [ - ["GET", "/api/doc"], - ["POST", "/api/doc/exists"], - ["GET", "/api/reference/files"], - ]), - ], - archive: [ - endpointGroup("archive-bootstrap", "Archive browse and close", [ - ["GET", "/api/plan"], - ["GET", "/api/archive/plans"], - ["GET", "/api/archive/plan"], - ["POST", "/api/done"], - ]), - ], - "setup-goal": [ - { - id: "setup-goal-contract", - title: "Setup-goal backend contract", - status: "planned", - reason: "The current source checkout does not expose setup-goal daemon endpoints yet.", - endpoints: [], - }, - ], - shared: [ - endpointGroup("external-annotations", "External annotation stream and CRUD", [ - ["GET", "/api/external-annotations/stream"], - ["GET", "/api/external-annotations"], - ["POST", "/api/external-annotations"], - ["PATCH", "/api/external-annotations"], - ["DELETE", "/api/external-annotations"], - ]), - endpointGroup("editor-annotations", "VS Code editor annotation bridge", [ - ["GET", "/api/editor-annotations"], - ["POST", "/api/editor-annotation"], - ["DELETE", "/api/editor-annotation"], - ]), - endpointGroup("agents", "Agent list and background jobs", [ - ["GET", "/api/agents"], - ["GET", "/api/agents/capabilities"], - ["GET", "/api/agents/jobs/stream"], - ["GET", "/api/agents/jobs"], - ["POST", "/api/agents/jobs"], - ["DELETE", "/api/agents/jobs"], - ["DELETE", "/api/agents/jobs/:id"], - ]), - endpointGroup("files", "Images, uploads, docs, config, and drafts", [ - ["GET", "/api/image"], - ["POST", "/api/upload"], - ["GET", "/api/doc"], - ["POST", "/api/doc/exists"], - ["POST", "/api/config"], - ["GET", "/api/draft"], - ["POST", "/api/draft"], - ["DELETE", "/api/draft"], - ]), - endpointGroup("notes", "Notes and reference integrations", [ - ["POST", "/api/save-notes"], - ["GET", "/api/obsidian/vaults"], - ["GET", "/api/reference/obsidian/files"], - ["GET", "/api/reference/obsidian/doc"], - ]), - ], -}; - -export function apiGroupsForMode(mode: ShellSessionMode): SessionApiGroup[] { - return sessionApiGroups[mode] ?? []; -} - -export function sharedApiGroups(): SessionApiGroup[] { - return sessionApiGroups.shared; -} - -function endpointGroup( - id: string, - title: string, - endpoints: Array<[method: string, path: string]>, -): SessionApiGroup { - return { - id, - title, - status: "ready", - endpoints: endpoints.map(([method, path]) => ({ method, path })), - }; -} diff --git a/apps/debug-frontend/src/sessions/session-id.test.ts b/apps/debug-frontend/src/sessions/session-id.test.ts deleted file mode 100644 index b3d4bf248..000000000 --- a/apps/debug-frontend/src/sessions/session-id.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { encodeSessionId, isValidSessionId, parseSessionId } from "./session-id"; - -describe("session id validation", () => { - test("accepts daemon-style session ids", () => { - expect(isValidSessionId("plan-session-1")).toBe(true); - expect(isValidSessionId("review_ABC123")).toBe(true); - expect(parseSessionId("archive-session-4")).toBe("archive-session-4"); - }); - - test("rejects path-like or empty ids", () => { - expect(isValidSessionId("")).toBe(false); - expect(isValidSessionId("../plan-session-1")).toBe(false); - expect(isValidSessionId("plan/session")).toBe(false); - expect(parseSessionId("plan/session")).toBe(false); - }); - - test("throws before encoding invalid ids", () => { - expect(() => encodeSessionId("bad/session")).toThrow("Invalid Plannotator session id"); - }); -}); diff --git a/apps/debug-frontend/src/sessions/session-id.ts b/apps/debug-frontend/src/sessions/session-id.ts deleted file mode 100644 index 3fdf22bb0..000000000 --- a/apps/debug-frontend/src/sessions/session-id.ts +++ /dev/null @@ -1,16 +0,0 @@ -const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/; - -export function isValidSessionId(value: string): boolean { - return SESSION_ID_PATTERN.test(value); -} - -export function parseSessionId(value: string): string | false { - return isValidSessionId(value) ? value : false; -} - -export function encodeSessionId(value: string): string { - if (!isValidSessionId(value)) { - throw new Error(`Invalid Plannotator session id: ${value}`); - } - return encodeURIComponent(value); -} diff --git a/apps/debug-frontend/src/sessions/session-view-registry.test.tsx b/apps/debug-frontend/src/sessions/session-view-registry.test.tsx deleted file mode 100644 index a2e3124ae..000000000 --- a/apps/debug-frontend/src/sessions/session-view-registry.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { PLANNOTATOR_DAEMON_SESSION_VIEWS } from "@plannotator/shared/daemon-protocol"; -import { getSessionViewDefinition, supportedSessionModes } from "./session-view-registry"; - -describe("session view registry", () => { - test("registers every daemon-supported shell view", () => { - expect(supportedSessionModes().sort()).toEqual([...PLANNOTATOR_DAEMON_SESSION_VIEWS].sort()); - }); - - test("returns undefined for unsupported session modes", () => { - expect(getSessionViewDefinition("future-mode")).toBeUndefined(); - }); -}); diff --git a/apps/debug-frontend/src/sessions/session-view-registry.tsx b/apps/debug-frontend/src/sessions/session-view-registry.tsx deleted file mode 100644 index cc9131063..000000000 --- a/apps/debug-frontend/src/sessions/session-view-registry.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReactNode } from "react"; -import { AnnotateSessionView } from "../annotate/AnnotateSessionView"; -import { ArchiveSessionView } from "../archive/ArchiveSessionView"; -import type { ShellSessionBootstrap, ShellSessionMode } from "./types"; -import { PlanSessionView } from "../plan/PlanSessionView"; -import { ReviewSessionView } from "../review/ReviewSessionView"; -import { SetupGoalSessionView } from "../setup-goal/SetupGoalSessionView"; - -export interface SessionViewComponentProps { - bootstrap: ShellSessionBootstrap; -} - -export type SessionViewComponent = (props: SessionViewComponentProps) => ReactNode; - -export interface SessionViewDefinition { - mode: ShellSessionMode; - title: string; - component: SessionViewComponent; -} - -const registry: Record = { - plan: { - mode: "plan", - title: "Plan review", - component: PlanSessionView, - }, - review: { - mode: "review", - title: "Code review", - component: ReviewSessionView, - }, - annotate: { - mode: "annotate", - title: "Annotate", - component: AnnotateSessionView, - }, - archive: { - mode: "archive", - title: "Archive", - component: ArchiveSessionView, - }, - "setup-goal": { - mode: "setup-goal", - title: "Setup goal", - component: SetupGoalSessionView, - }, -}; - -export function getSessionViewDefinition( - mode: ShellSessionMode, -): SessionViewDefinition | undefined { - return registry[mode]; -} - -export function supportedSessionModes(): ShellSessionMode[] { - return Object.keys(registry); -} diff --git a/apps/debug-frontend/src/sessions/state/session-store.test.ts b/apps/debug-frontend/src/sessions/state/session-store.test.ts deleted file mode 100644 index 63c5ce4c9..000000000 --- a/apps/debug-frontend/src/sessions/state/session-store.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { sessionBootstrap, sessionListFixture } from "../../testing/fixtures/daemon"; -import { createSessionStore } from "./session-store"; - -describe("session store", () => { - test("keeps summaries keyed by session id", () => { - const store = createSessionStore(); - - store.getState().setSessions(sessionListFixture.sessions); - - expect(store.getState().sessionOrder).toEqual([ - "plan-session-1", - "review-session-2", - "annotate-session-3", - "archive-session-4", - "setup-goal-session-5", - ]); - expect(store.getState().sessions["review-session-2"].summary?.mode).toBe("review"); - }); - - test("updates bootstrap state without touching other sessions", () => { - const store = createSessionStore(); - store.getState().setSessions(sessionListFixture.sessions); - - store.getState().setBootstrap(sessionBootstrap("review", 2)); - - expect(store.getState().sessions["review-session-2"].loadState).toBe("ready"); - expect(store.getState().sessions["plan-session-1"].loadState).toBe("idle"); - }); - - test("uses Immer for nested status updates without mutating previous snapshots", () => { - const store = createSessionStore(); - store.getState().setBootstrap(sessionBootstrap("plan", 1)); - const previousBootstrap = store.getState().sessions["plan-session-1"].bootstrap; - - store.getState().setSessionStatus("plan-session-1", "completed"); - - expect(previousBootstrap?.session.status).toBe("active"); - expect(store.getState().sessions["plan-session-1"].bootstrap?.session.status).toBe("completed"); - }); -}); diff --git a/apps/debug-frontend/src/sessions/state/session-store.ts b/apps/debug-frontend/src/sessions/state/session-store.ts deleted file mode 100644 index f8fb9f554..000000000 --- a/apps/debug-frontend/src/sessions/state/session-store.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createStore } from "zustand/vanilla"; -import { useStore } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import type { DaemonApiError } from "../../daemon/api/errors"; -import type { ShellSessionBootstrap, ShellSessionSummary } from "../types"; - -export type SessionLoadState = "idle" | "loading" | "ready" | "error"; - -export interface SessionRecordState { - loadState: SessionLoadState; - summary?: ShellSessionSummary; - bootstrap?: ShellSessionBootstrap; - error?: DaemonApiError; -} - -export interface SessionStoreState { - selectedSessionId?: string; - sessions: Record; - sessionOrder: string[]; -} - -export interface SessionStoreActions { - setSelectedSession(sessionId: string | undefined): void; - setSessions(sessions: ShellSessionSummary[]): void; - markLoading(sessionId: string): void; - setBootstrap(bootstrap: ShellSessionBootstrap): void; - setSessionError(sessionId: string, error: DaemonApiError): void; - setSessionStatus(sessionId: string, status: ShellSessionSummary["status"]): void; - removeSession(sessionId: string): void; - reset(): void; -} - -export type SessionStore = SessionStoreState & SessionStoreActions; - -const initialSessionState: SessionStoreState = { - selectedSessionId: undefined, - sessions: {}, - sessionOrder: [], -}; - -export function createSessionStore(initial: Partial = {}) { - return createStore()( - immer((set) => ({ - ...initialSessionState, - ...initial, - setSelectedSession(sessionId) { - set((state) => { - state.selectedSessionId = sessionId; - }); - }, - setSessions(sessions) { - set((state) => { - state.sessionOrder = sessions.map((session) => session.id); - for (const session of sessions) { - const existing = state.sessions[session.id]; - state.sessions[session.id] = { - loadState: existing?.loadState ?? "idle", - summary: session, - bootstrap: existing?.bootstrap, - error: undefined, - }; - } - - for (const id of Object.keys(state.sessions)) { - if (!state.sessionOrder.includes(id)) { - delete state.sessions[id]; - } - } - }); - }, - markLoading(sessionId) { - set((state) => { - state.sessions[sessionId] = { - ...state.sessions[sessionId], - loadState: "loading", - error: undefined, - }; - }); - }, - setBootstrap(bootstrap) { - set((state) => { - const id = bootstrap.session.id; - if (!state.sessionOrder.includes(id)) { - state.sessionOrder.push(id); - } - state.sessions[id] = { - loadState: "ready", - summary: bootstrap.session, - bootstrap, - error: undefined, - }; - }); - }, - setSessionError(sessionId, error) { - set((state) => { - state.sessions[sessionId] = { - ...state.sessions[sessionId], - loadState: "error", - error, - }; - }); - }, - setSessionStatus(sessionId, status) { - set((state) => { - const record = state.sessions[sessionId]; - if (!record) return; - if (record.summary) record.summary.status = status; - if (record.bootstrap) record.bootstrap.session.status = status; - }); - }, - removeSession(sessionId) { - set((state) => { - delete state.sessions[sessionId]; - state.sessionOrder = state.sessionOrder.filter((id) => id !== sessionId); - if (state.selectedSessionId === sessionId) { - state.selectedSessionId = undefined; - } - }); - }, - reset() { - set((state) => { - state.selectedSessionId = undefined; - state.sessions = {}; - state.sessionOrder = []; - }); - }, - })), - ); -} - -export const sessionStore = createSessionStore(); - -export function useSessionStore(selector: (state: SessionStore) => T): T { - return useStore(sessionStore, selector); -} diff --git a/apps/debug-frontend/src/sessions/types.ts b/apps/debug-frontend/src/sessions/types.ts deleted file mode 100644 index b76e04b6a..000000000 --- a/apps/debug-frontend/src/sessions/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { - ShellSessionBootstrap, - ShellSessionLifecycleStatus, - ShellSessionListResponse, - ShellSessionMode, - ShellSessionSummary, - ShellSessionView, -} from "../daemon/contracts"; diff --git a/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx b/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx deleted file mode 100644 index 1e8389255..000000000 --- a/apps/debug-frontend/src/setup-goal/SetupGoalSessionView.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { apiGroupsForMode } from "../sessions/session-api-groups"; -import { ApiGroupList } from "../shared/ui/ApiGroupList"; -import { SessionFacts } from "../shared/ui/SessionFacts"; -import type { SessionViewComponentProps } from "../sessions/session-view-registry"; - -export function SetupGoalSessionView({ bootstrap }: SessionViewComponentProps) { - return ( -
    -
    -

    Setup goal

    -

    {bootstrap.session.label}

    -

    - Fixture-backed shell view for future setup-goal interviews, fact sheets, plan generation, - and Plannotator review gates. The backend contract is planned until the source runtime - exposes it. -

    -
    - - -
    - ); -} diff --git a/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx b/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx deleted file mode 100644 index a00d8fbd6..000000000 --- a/apps/debug-frontend/src/shared/ui/ApiGroupList.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { SessionApiGroup } from "../../sessions/session-api-groups"; - -interface ApiGroupListProps { - groups: SessionApiGroup[]; -} - -const STATUS_LABELS: Record = { - planned: "Planned", - ready: "Ready", -}; - -export function ApiGroupList({ groups }: ApiGroupListProps) { - return ( -
    -
    -
    -

    Backend contract

    -

    Session API surface

    -
    - {groups.length} groups -
    -
    - {groups.map((group) => ( -
    -
    -

    {group.title}

    - - {STATUS_LABELS[group.status]} - -
    - {group.reason ?

    {group.reason}

    : null} - {group.endpoints.length > 0 ? ( -
      - {group.endpoints.slice(0, 8).map((endpoint) => ( -
    • - {endpoint.method} {endpoint.path} -
    • - ))} -
    - ) : ( -

    No endpoint contract in this checkout yet.

    - )} -
    - ))} -
    -
    - ); -} diff --git a/apps/debug-frontend/src/shared/ui/ResultNotice.tsx b/apps/debug-frontend/src/shared/ui/ResultNotice.tsx deleted file mode 100644 index 8ac0e57b5..000000000 --- a/apps/debug-frontend/src/shared/ui/ResultNotice.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ReactNode } from "react"; -import { errorMessage, type DaemonApiError } from "../../daemon/api/errors"; - -interface ResultNoticeProps { - tone: "info" | "error" | "empty"; - title: string; - children?: ReactNode; - error?: DaemonApiError; -} - -export function ResultNotice({ tone, title, children, error }: ResultNoticeProps) { - return ( -
    -

    {title}

    - {error ?

    {errorMessage(error)}

    : children} -
    - ); -} diff --git a/apps/debug-frontend/src/shared/ui/SessionFacts.tsx b/apps/debug-frontend/src/shared/ui/SessionFacts.tsx deleted file mode 100644 index eec7569c3..000000000 --- a/apps/debug-frontend/src/shared/ui/SessionFacts.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ShellSessionBootstrap } from "../../sessions/types"; - -interface SessionFactsProps { - bootstrap: ShellSessionBootstrap; -} - -export function SessionFacts({ bootstrap }: SessionFactsProps) { - const { session } = bootstrap; - - return ( -
    -
    -
    Session
    -
    {session.id}
    -
    -
    -
    Mode
    -
    {session.mode}
    -
    -
    -
    Project
    -
    {session.project}
    -
    -
    -
    Origin
    -
    {session.origin ?? "unknown"}
    -
    -
    -
    Status
    -
    {session.status}
    -
    -
    -
    API base
    -
    {bootstrap.apiBase}
    -
    -
    - ); -} diff --git a/apps/debug-frontend/src/styles.css b/apps/debug-frontend/src/styles.css deleted file mode 100644 index 50d1128af..000000000 --- a/apps/debug-frontend/src/styles.css +++ /dev/null @@ -1,515 +0,0 @@ -@import "@plannotator/ui/theme.css"; - -* { - box-sizing: border-box; -} - -html, -body, -#root { - min-height: 100%; - margin: 0; -} - -body { - background: var(--background); - color: var(--foreground); - font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; - line-height: 1.5; - -webkit-user-select: text; - user-select: text; -} - -a { - color: inherit; -} - -code { - font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.92em; -} - -.muted-line { - color: var(--muted-foreground); - font-size: 0.84rem; -} - -/* Shell layout */ - -.app-shell { - min-height: 100vh; - background: color-mix(in srgb, var(--background) 96%, var(--muted)); -} - -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 10px clamp(16px, 3vw, 36px); - border-bottom: 1px solid var(--border); - background: var(--background); -} - -.app-header h1 { - margin: 0; - font-size: 1.1rem; - letter-spacing: 0; -} - -.app-header nav { - display: flex; - gap: 12px; -} - -.app-header a { - padding: 6px 9px; - border: 1px solid var(--border); - border-radius: 6px; - color: var(--muted-foreground); - font-size: 0.88rem; - text-decoration: none; -} - -.app-header a[aria-current="page"] { - color: var(--foreground); - border-color: var(--ring); -} - -.app-main { - width: min(900px, calc(100vw - 28px)); - margin: 0 auto; - padding: 24px 0 40px; -} - -.app-shell button, -.app-shell summary, -.app-shell nav { - -webkit-user-select: none; - user-select: none; -} - -/* Page heading */ - -.page-heading { - margin-bottom: 20px; -} - -.page-heading h2 { - margin: 0; - font-size: 1.3rem; - letter-spacing: 0; -} - -.page-heading p { - margin: 4px 0 0; -} - -/* Status pills */ - -.status-pill { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 7px; - border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted-foreground); - font-size: 0.75rem; - font-weight: 700; -} - -.status-active, -.status-ready, -.status-open, -.status-polling { - color: var(--success); - border-color: color-mix(in srgb, var(--success) 45%, var(--border)); -} - -.status-connecting, -.status-error { - color: var(--warning); - border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); -} - -/* Notice / empty state */ - -.notice { - padding: 20px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card); -} - -.notice p { - margin: 0; - color: var(--muted-foreground); -} - -.notice-error { - border-color: color-mix(in srgb, var(--destructive) 60%, var(--border)); -} - -/* Session list */ - -.session-list { - display: grid; - gap: 8px; - margin: 0 0 24px; - padding: 0; - list-style: none; -} - -.session-card { - display: grid; - grid-template-columns: 1fr auto; - gap: 4px 16px; - align-items: center; - padding: 12px 14px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card); -} - -.session-card-header { - display: flex; - align-items: center; - gap: 10px; - grid-column: 1; -} - -.session-card-label { - grid-column: 1; - overflow: hidden; - font-size: 0.9rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-card-id { - grid-column: 1; - color: var(--muted-foreground); - font-size: 0.78rem; -} - -.session-card-open { - grid-column: 2; - grid-row: 1 / 4; - display: inline-flex; - align-items: center; - padding: 7px 14px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--primary); - color: var(--primary-foreground); - font-size: 0.88rem; - font-weight: 600; - text-decoration: none; -} - -.session-card-open:hover { - opacity: 0.9; -} - -/* Event log */ - -.event-log-section { - margin-top: 32px; -} - -.event-log-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} - -.event-log-header h3 { - margin: 0; - font-size: 0.88rem; - color: var(--muted-foreground); -} - -.event-log { - margin: 0; - padding: 0; - list-style: none; - font-size: 0.8rem; - font-family: var(--font-mono), ui-monospace, monospace; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--card); - max-height: 320px; - overflow-y: auto; -} - -.event-row { - display: flex; - gap: 10px; - padding: 5px 10px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); -} - -.event-row:last-child { - border-bottom: none; -} - -.event-row time { - color: var(--muted-foreground); - white-space: nowrap; - min-width: 9ch; -} - -.event-type { - color: var(--primary); - font-weight: 600; - white-space: nowrap; -} - -.event-source { - color: var(--accent); - white-space: nowrap; -} - -.event-row code { - color: var(--muted-foreground); -} - -.event-row-debug { - color: var(--muted-foreground); -} - -/* Session debug panel */ - -.session-debug { - margin-top: 32px; - display: grid; - gap: 16px; -} - -.session-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.action-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; - min-width: 120px; - padding: 9px 20px; - border: none; - border-radius: 8px; - font: inherit; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; -} - -.action-btn:disabled { - cursor: wait; - opacity: 0.5; -} - -.action-approve { - background: var(--success); - color: var(--success-foreground); -} - -.action-approve:hover:not(:disabled) { - opacity: 0.9; -} - -.action-deny { - background: var(--destructive); - color: var(--destructive-foreground); -} - -.action-deny:hover:not(:disabled) { - opacity: 0.9; -} - -.action-secondary { - background: var(--muted); - color: var(--foreground); - border: 1px solid var(--border); -} - -.debug-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.debug-actions button { - display: inline-flex; - align-items: center; - min-height: 34px; - padding: 7px 12px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--background); - color: var(--muted-foreground); - font: inherit; - font-size: 0.82rem; - cursor: pointer; -} - -.debug-actions button:disabled { - cursor: wait; - opacity: 0.6; -} - -.debug-result { - border: 1px solid var(--border); - border-radius: 6px; - background: var(--card); -} - -.debug-result summary { - padding: 8px 12px; - cursor: pointer; - font-size: 0.84rem; - font-weight: 600; - color: var(--muted-foreground); -} - -.json-block { - max-height: 300px; - margin: 0; - overflow: auto; - padding: 10px 12px; - border-top: 1px solid var(--border); - background: var(--muted); - color: var(--muted-foreground); - font-size: 0.78rem; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -/* Session view (when viewing /s/:id) */ - -.session-panel { - padding: 16px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card); -} - -.session-panel header { - margin-bottom: 14px; -} - -.session-panel h2 { - margin: 0; - font-size: 1.2rem; - letter-spacing: 0; -} - -.session-panel header p { - margin: 6px 0 0; - color: var(--muted-foreground); -} - -.session-facts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 8px; - margin: 0 0 16px; -} - -.session-facts div { - min-width: 0; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--muted); -} - -.session-facts dt { - color: var(--muted-foreground); - font-size: 0.78rem; - font-weight: 700; -} - -.session-facts dd { - margin: 4px 0 0; - overflow-wrap: anywhere; -} - -.section-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 10px; -} - -.section-heading h2 { - margin: 0; - font-size: 1rem; - letter-spacing: 0; -} - -.eyebrow { - margin: 0 0 4px; - color: var(--muted-foreground); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.api-group-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); - gap: 8px; -} - -.api-group { - min-width: 0; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 6px; - background: color-mix(in srgb, var(--card) 80%, var(--muted)); -} - -.api-group h3 { - margin: 0; - font-size: 0.9rem; -} - -.api-group p, -.api-group li { - color: var(--muted-foreground); -} - -.api-group ul { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 4px 10px; - margin: 8px 0 0; - padding: 0; - list-style: none; - font-size: 0.8rem; -} - -.api-group p { - margin: 8px 0 0; -} - -@media (max-width: 720px) { - .app-header, - .page-heading { - align-items: stretch; - flex-direction: column; - } - - .app-main { - width: min(100% - 24px, 900px); - padding-top: 20px; - } -} diff --git a/apps/debug-frontend/src/testing/browser/render.tsx b/apps/debug-frontend/src/testing/browser/render.tsx deleted file mode 100644 index 1fd3de235..000000000 --- a/apps/debug-frontend/src/testing/browser/render.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -export async function renderBrowser( - ui: ReactNode, -): Promise<{ container: HTMLElement; root: Root }> { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - await act(async () => { - root.render(ui); - }); - return { container, root }; -} - -export async function cleanupBrowser(root: Root, container: HTMLElement): Promise { - await act(async () => { - root.unmount(); - }); - container.remove(); -} diff --git a/apps/debug-frontend/src/testing/fetch.ts b/apps/debug-frontend/src/testing/fetch.ts deleted file mode 100644 index 4db8f5bd0..000000000 --- a/apps/debug-frontend/src/testing/fetch.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface RecordedRequest { - url: string; - init?: RequestInit; -} - -export interface FixtureFetch { - fetch: typeof fetch; - requests: RecordedRequest[]; -} - -export function createFixtureFetch(routes: Record): FixtureFetch { - const requests: RecordedRequest[] = []; - - const fixtureFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const normalized = normalizeFixtureUrl(url); - requests.push({ url: normalized, init }); - - const route = routes[normalized]; - if (route instanceof Response) return route; - if (route === undefined) { - return Response.json( - { - ok: false, - protocol: "plannotator-daemon", - protocolVersion: 1, - error: { - code: "session-not-found", - message: `No fixture route for ${normalized}`, - }, - }, - { status: 404 }, - ); - } - return Response.json(route); - }; - - return { fetch: fixtureFetch as typeof fetch, requests }; -} - -function normalizeFixtureUrl(url: string): string { - if (url.startsWith("http://") || url.startsWith("https://")) { - return new URL(url).pathname + new URL(url).search; - } - return url; -} diff --git a/apps/debug-frontend/src/testing/fixtures/daemon.test.ts b/apps/debug-frontend/src/testing/fixtures/daemon.test.ts deleted file mode 100644 index f2e9a4f9f..000000000 --- a/apps/debug-frontend/src/testing/fixtures/daemon.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { PLANNOTATOR_DAEMON_SESSION_VIEWS } from "@plannotator/shared/daemon-protocol"; -import { sessionApiGroups } from "../../sessions/session-api-groups"; -import { sessionBootstraps, sessionListFixture } from "./daemon"; - -describe("daemon fixtures", () => { - test("include a bootstrap fixture for every supported shell view", () => { - for (const mode of PLANNOTATOR_DAEMON_SESSION_VIEWS) { - expect(sessionBootstraps[mode].session.mode).toBe(mode); - } - }); - - test("include dashboard sessions for every supported shell view", () => { - const modes = sessionListFixture.sessions.map((session) => session.mode).sort(); - expect(modes).toEqual([...PLANNOTATOR_DAEMON_SESSION_VIEWS].sort()); - }); - - test("represent every inventory API group with scoped API paths or explicit deferral", () => { - for (const [mode, groups] of Object.entries(sessionApiGroups)) { - expect(groups.length, `${mode} has groups`).toBeGreaterThan(0); - for (const group of groups) { - if (group.status === "planned") { - expect(group.reason).toBeTruthy(); - continue; - } - expect(group.endpoints.length, `${group.id} has endpoints`).toBeGreaterThan(0); - for (const endpoint of group.endpoints) { - expect(endpoint.path.startsWith("/api/") || endpoint.path === "/favicon.svg").toBe(true); - } - } - } - }); -}); diff --git a/apps/debug-frontend/src/testing/fixtures/daemon.ts b/apps/debug-frontend/src/testing/fixtures/daemon.ts deleted file mode 100644 index b71ccafa9..000000000 --- a/apps/debug-frontend/src/testing/fixtures/daemon.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - PLANNOTATOR_DAEMON_SESSION_VIEWS, - getDaemonCapabilities, -} from "@plannotator/shared/daemon-protocol"; -import type { - ShellDaemonStatus, - ShellSessionBootstrap, - ShellSessionListResponse, - ShellSessionMode, - ShellSessionSummary, -} from "../../daemon/contracts"; - -const now = "2026-05-17T12:00:00.000Z"; - -export const daemonCapabilities = getDaemonCapabilities(); - -export const daemonStatusFixture: ShellDaemonStatus = { - ok: true, - protocol: "plannotator-daemon", - protocolVersion: 1, - pid: 4242, - endpoint: { - hostname: "127.0.0.1", - port: 19432, - baseUrl: "http://127.0.0.1:19432", - isRemote: false, - }, - startedAt: now, - activeSessionCount: 5, - sessionCount: 5, -}; - -export function sessionSummary(mode: ShellSessionMode, index = 1): ShellSessionSummary { - return { - id: `${mode}-session-${index}`, - mode, - status: "active", - url: `http://127.0.0.1:19432/s/${mode}-session-${index}`, - project: mode === "archive" ? "Personal archive" : "plannotator", - label: sessionLabel(mode), - origin: index % 2 === 0 ? "opencode" : "claude-code", - createdAt: now, - updatedAt: now, - expiresAt: "2026-05-21T12:00:00.000Z", - }; -} - -function sessionLabel(mode: ShellSessionMode): string { - switch (mode) { - case "plan": - return "Runtime frontend shell plan"; - case "review": - return "PR #734 daemon runtime review"; - case "annotate": - return "Annotate docs/runtime.md"; - case "archive": - return "Plan decision archive"; - case "setup-goal": - return "Setup goal interview"; - default: - return `Unsupported ${mode}`; - } -} - -export const sessionListFixture: ShellSessionListResponse = { - ok: true, - sessions: [ - sessionSummary("plan", 1), - sessionSummary("review", 2), - sessionSummary("annotate", 3), - sessionSummary("archive", 4), - sessionSummary("setup-goal", 5), - ], -}; - -export function sessionBootstrap(mode: ShellSessionMode, index = 1): ShellSessionBootstrap { - const session = sessionSummary(mode, index); - return { - ok: true, - session, - apiBase: `/s/${session.id}/api`, - capabilities: daemonCapabilities, - supportedSessionViews: [...PLANNOTATOR_DAEMON_SESSION_VIEWS], - }; -} - -export const sessionBootstraps = { - plan: sessionBootstrap("plan", 1), - review: sessionBootstrap("review", 2), - annotate: sessionBootstrap("annotate", 3), - archive: sessionBootstrap("archive", 4), - "setup-goal": sessionBootstrap("setup-goal", 5), - unsupported: sessionBootstrap("unknown-mode", 6), -} as const; - -export const emptySessionListFixture: ShellSessionListResponse = { - ok: true, - sessions: [], -}; diff --git a/apps/debug-frontend/vite.config.ts b/apps/debug-frontend/vite.config.ts deleted file mode 100644 index e42db6c2a..000000000 --- a/apps/debug-frontend/vite.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import path from "path"; -import { tanstackRouter } from "@tanstack/router-plugin/vite"; -import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig({ - server: { - port: 3002, - host: "0.0.0.0", - }, - plugins: [ - tanstackRouter({ - target: "react", - routesDirectory: "./src/routes", - generatedRouteTree: "./src/routeTree.gen.ts", - quoteStyle: "double", - }), - react(), - tailwindcss(), - viteSingleFile(), - ], - resolve: { - alias: { - "@": path.resolve(__dirname, "src"), - "@plannotator/shared": path.resolve(__dirname, "../../packages/shared"), - "@plannotator/ui": path.resolve(__dirname, "../../packages/ui"), - }, - }, - build: { - target: "esnext", - assetsInlineLimit: 100000000, - chunkSizeWarningLimit: 100000000, - cssCodeSplit: false, - rollupOptions: { - output: { - inlineDynamicImports: true, - }, - }, - }, -}); diff --git a/apps/debug-tui/README.md b/apps/debug-tui/README.md deleted file mode 100644 index 064b3c961..000000000 --- a/apps/debug-tui/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# @plannotator/debug-tui - -Terminal UI agent simulator for testing daemon sessions. **Not production code** — this exercises -Plannotator's real agent protocols against the daemon runtime using local fixtures. - -The simulator spawns the same `plannotator` commands that agents use, writes realistic stdin -payloads, captures stdout/stderr, watches for `PLANNOTATOR_SESSION_READY`, and can complete sessions -through the daemon-scoped browser API. Multiple sessions can run concurrently. - -## Commands - -```bash -bun run --cwd apps/debug-tui start -bun run --cwd apps/debug-tui run -- --scenario opencode-plan -bun run --cwd apps/debug-tui test:e2e -``` - -Or from the repo root: - -```bash -bun run dev:debug-tui -bun run check:debug-tui -bun run dev:debug-stack # starts daemon + opens browser + launches TUI -``` - -## TUI keys - -- `Enter` — start selected scenario -- `a` — start all scenarios concurrently -- `m` — toggle manual/auto-complete mode -- `c` — copy latest log to clipboard -- `p` — copy log file path -- `q` — quit - -Fixtures are local: temporary workspaces, local git repositories, markdown/html files, and archived -plan files. External hosted agents, live PR URLs, and hosted sharing services are not required. diff --git a/apps/debug-tui/package.json b/apps/debug-tui/package.json deleted file mode 100644 index f6b54b25f..000000000 --- a/apps/debug-tui/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@plannotator/debug-tui", - "description": "Terminal UI agent simulator for testing daemon sessions. Not production code.", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "start": "bun run src/main.tsx", - "run": "bun run src/cli.ts", - "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "vitest run", - "test:e2e": "PLANNOTATOR_AGENT_SIMULATOR_E2E=1 vitest run src/e2e/full-loop.test.ts", - "check": "bun run typecheck && bun run test" - }, - "dependencies": { - "@opentui/core": "0.2.6", - "@opentui/react": "0.2.6", - "@plannotator/shared": "workspace:*", - "react": "^19.2.3" - }, - "devDependencies": { - "@types/node": "^22.14.0", - "@types/react": "^19.2.0", - "typescript": "~5.8.2", - "vitest": "^4.0.16" - } -} diff --git a/apps/debug-tui/src/cli.ts b/apps/debug-tui/src/cli.ts deleted file mode 100644 index c39128fda..000000000 --- a/apps/debug-tui/src/cli.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { authTokenFromBrowserUrl } from "./daemon/client"; -import { getScenario, scenarioDefinitions } from "./scenarios"; -import { runScenario } from "./scenarios/run-scenario"; -import type { ScenarioId } from "./scenarios/types"; - -const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); -const args = process.argv.slice(2); - -if (args.includes("--list")) { - for (const scenario of scenarioDefinitions) { - console.log(`${scenario.id}\t${scenario.agent}\t${scenario.title}`); - } - process.exit(0); -} - -const scenarioId = readArg("--scenario") as ScenarioId | undefined; -const runAll = args.includes("--all"); -const json = args.includes("--json"); -const manual = args.includes("--manual"); -const sharedDaemon = args.includes("--shared-daemon"); - -if (!scenarioId && !runAll) { - console.error("Usage: bun run src/cli.ts -- --scenario [--json] [--manual] [--shared-daemon]"); - console.error(" bun run src/cli.ts -- --all [--json]"); - console.error(" bun run src/cli.ts -- --list"); - process.exit(1); -} - -const selected = runAll ? scenarioDefinitions : [getScenario(scenarioId as ScenarioId)]; -const results = []; - -for (const scenario of selected) { - const result = await runScenario(scenario, { - repoRoot, - completion: manual ? "manual" : "auto", - useSharedDaemon: sharedDaemon, - stopDaemonOnFinish: !sharedDaemon, - daemonBaseUrl: process.env.PLANNOTATOR_SIMULATOR_DAEMON_URL, - daemonAuthToken: authTokenFromBrowserUrl(process.env.PLANNOTATOR_SIMULATOR_DAEMON_BROWSER_URL), - onLog: (entry) => { - if (!json) console.error(`[${scenario.id}] ${entry.message}`); - }, - }); - results.push({ - id: scenario.id, - exitCode: result.process.exitCode, - session: result.session, - stdout: result.process.stdout, - stderr: result.process.stderr, - }); -} - -if (json) { - console.log(JSON.stringify({ ok: true, results }, null, 2)); -} else { - for (const result of results) { - console.log(`${result.id}: exit=${result.exitCode} session=${result.session?.url ?? "none"}`); - } -} - -function readArg(name: string): string | undefined { - const index = args.indexOf(name); - if (index === -1) return undefined; - return args[index + 1]; -} diff --git a/apps/debug-tui/src/clipboard.test.ts b/apps/debug-tui/src/clipboard.test.ts deleted file mode 100644 index 6bdf4e1d8..000000000 --- a/apps/debug-tui/src/clipboard.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { clipboardCandidates } from "./clipboard"; - -describe("agent simulator clipboard candidates", () => { - test("uses pbcopy on macOS", () => { - expect(clipboardCandidates("darwin")).toEqual([{ command: "pbcopy", args: [] }]); - }); - - test("uses cmd clip on Windows", () => { - expect(clipboardCandidates("win32")).toEqual([{ command: "cmd", args: ["/c", "clip"] }]); - }); - - test("tries common Linux clipboard commands", () => { - expect(clipboardCandidates("linux").map((candidate) => candidate.command)).toEqual([ - "wl-copy", - "xclip", - "xsel", - ]); - }); -}); diff --git a/apps/debug-tui/src/clipboard.ts b/apps/debug-tui/src/clipboard.ts deleted file mode 100644 index dbff72d22..000000000 --- a/apps/debug-tui/src/clipboard.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { spawn } from "node:child_process"; - -export interface ClipboardResult { - ok: boolean; - message: string; -} - -interface ClipboardCommand { - command: string; - args: string[]; -} - -export function clipboardCandidates(platform: NodeJS.Platform = process.platform): ClipboardCommand[] { - if (platform === "darwin") return [{ command: "pbcopy", args: [] }]; - if (platform === "win32") return [{ command: "cmd", args: ["/c", "clip"] }]; - return [ - { command: "wl-copy", args: [] }, - { command: "xclip", args: ["-selection", "clipboard"] }, - { command: "xsel", args: ["--clipboard", "--input"] }, - ]; -} - -export async function copyTextToClipboard(text: string): Promise { - if (!text) return { ok: false, message: "Nothing to copy." }; - - const errors: string[] = []; - for (const candidate of clipboardCandidates()) { - const result = await runClipboardCommand(candidate, text); - if (result.ok) return { ok: true, message: "Copied to clipboard." }; - errors.push(result.message); - } - - return { - ok: false, - message: `Clipboard copy failed: ${errors.filter(Boolean).join("; ") || "no clipboard command found"}`, - }; -} - -function runClipboardCommand(candidate: ClipboardCommand, text: string): Promise { - return new Promise((resolve) => { - const child = spawn(candidate.command, candidate.args, { - stdio: ["pipe", "ignore", "pipe"], - }); - let stderr = ""; - let settled = false; - - const settle = (result: ClipboardResult) => { - if (settled) return; - settled = true; - resolve(result); - }; - - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; - }); - child.on("error", (err: NodeJS.ErrnoException) => { - settle({ - ok: false, - message: err.code === "ENOENT" ? `${candidate.command} not found` : err.message, - }); - }); - child.on("close", (code) => { - settle({ - ok: code === 0, - message: code === 0 ? "copied" : `${candidate.command} exited ${code}: ${stderr.trim()}`, - }); - }); - child.stdin.end(text); - }); -} diff --git a/apps/debug-tui/src/daemon/client.test.ts b/apps/debug-tui/src/daemon/client.test.ts deleted file mode 100644 index 31abc3df6..000000000 --- a/apps/debug-tui/src/daemon/client.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { completeSession, createSimulatorDaemonClient } from "./client"; - -describe("simulator daemon completion client", () => { - test.each([ - ["plan", "/api/approve", { planSave: { enabled: false } }], - ["review", "/api/feedback", { approved: true, feedback: "LGTM", annotations: [] }], - ["annotate", "/api/approve", {}], - ["archive", "/api/done", {}], - ] as const)("completes %s sessions through the session API", async (mode, path, body) => { - const requests: { url: string; init?: RequestInit }[] = []; - const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.toString(); - requests.push({ url, init }); - return Response.json({ ok: true }); - }) as typeof fetch; - - await completeSession(fetchImpl, "http://127.0.0.1:19432/s/session-1", mode); - - expect(requests[0].url).toBe(`http://127.0.0.1:19432/s/session-1${path}`); - expect(requests[0].init?.method).toBe("POST"); - expect(JSON.parse(String(requests[0].init?.body))).toEqual(body); - }); - - test("posts debug logs to the daemon event stream", async () => { - const requests: { url: string; init?: RequestInit }[] = []; - const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.toString(); - requests.push({ url, init }); - return Response.json({ ok: true }); - }) as typeof fetch; - - await createSimulatorDaemonClient("http://127.0.0.1:19432", fetchImpl, { authToken: "secret" }).postDebugLog({ - source: "agent-simulator", - scenarioId: "claude-plan-hook", - message: "queued claude-plan-hook", - }); - - expect(requests[0].url).toBe("http://127.0.0.1:19432/daemon/events/debug"); - expect(requests[0].init?.method).toBe("POST"); - expect(new Headers(requests[0].init?.headers).get("authorization")).toBe("Bearer secret"); - expect(JSON.parse(String(requests[0].init?.body))).toEqual({ - source: "agent-simulator", - scenarioId: "claude-plan-hook", - message: "queued claude-plan-hook", - }); - }); -}); diff --git a/apps/debug-tui/src/daemon/client.ts b/apps/debug-tui/src/daemon/client.ts deleted file mode 100644 index 6e5c9d2f8..000000000 --- a/apps/debug-tui/src/daemon/client.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { DaemonStatus } from "@plannotator/shared/daemon-protocol"; - -export interface SimulatorDaemonSession { - id: string; - mode: "plan" | "review" | "annotate" | "archive" | string; - status: string; - url: string; - project: string; - label: string; -} - -export interface SimulatorDaemonClient { - getStatus(): Promise; - listSessions(): Promise; - postDebugLog(event: SimulatorDebugLogEvent): Promise; - completeSession( - session: { url: string; mode: string }, - completion: "plan" | "review" | "annotate" | "archive", - ): Promise; - shutdown(): Promise; -} - -export interface SimulatorDebugLogEvent { - at?: string; - source: string; - scenarioId?: string; - message: string; - level?: "debug" | "info" | "warn" | "error"; - data?: unknown; -} - -export interface SimulatorDaemonClientOptions { - authToken?: string; -} - -export function createSimulatorDaemonClient( - baseUrl: string, - fetchImpl: typeof fetch = fetch, - options: SimulatorDaemonClientOptions = {}, -): SimulatorDaemonClient { - const daemonInit = (init?: RequestInit): RequestInit => withDaemonAuth(init, options.authToken); - - return { - async getStatus() { - return readJson(fetchImpl, `${baseUrl}/daemon/status`, daemonInit()); - }, - - async listSessions() { - const payload = await readJson<{ ok: true; sessions: SimulatorDaemonSession[] }>( - fetchImpl, - `${baseUrl}/daemon/sessions?clean=1`, - daemonInit(), - ); - return payload.sessions; - }, - - async postDebugLog(event) { - await readJson(fetchImpl, `${baseUrl}/daemon/events/debug`, daemonInit({ - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(event), - })); - }, - - completeSession(session, completion) { - return completeSession(fetchImpl, session.url, completion); - }, - - async shutdown() { - await readJson(fetchImpl, `${baseUrl}/daemon/shutdown`, daemonInit({ - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - })); - }, - }; -} - -export function authTokenFromBrowserUrl(browserUrl: string | undefined): string | undefined { - if (!browserUrl) return undefined; - try { - return new URL(browserUrl).searchParams.get("plannotator_auth") ?? undefined; - } catch { - return undefined; - } -} - -export async function completeSession( - fetchImpl: typeof fetch, - sessionUrl: string, - completion: "plan" | "review" | "annotate" | "archive" | "goal-setup", -): Promise { - switch (completion) { - case "plan": - return readJson(fetchImpl, `${sessionUrl}/api/approve`, postJson({ planSave: { enabled: false } })); - case "review": - return readJson( - fetchImpl, - `${sessionUrl}/api/feedback`, - postJson({ approved: true, feedback: "LGTM", annotations: [] }), - ); - case "annotate": - return readJson(fetchImpl, `${sessionUrl}/api/approve`, postJson({})); - case "archive": - return readJson(fetchImpl, `${sessionUrl}/api/done`, postJson({})); - case "goal-setup": - return readJson( - fetchImpl, - `${sessionUrl}/api/goal-setup/submit`, - postJson({ answers: [], facts: [] }), - ); - } -} - -function postJson(body: unknown): RequestInit { - return { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }; -} - -function withDaemonAuth(init: RequestInit = {}, authToken?: string): RequestInit { - if (!authToken) return init; - const headers = new Headers(init.headers); - if (!headers.has("Authorization")) { - headers.set("Authorization", `Bearer ${authToken}`); - } - return { ...init, headers }; -} - -async function readJson( - fetchImpl: typeof fetch, - url: string, - init?: RequestInit, -): Promise { - const response = await fetchImpl(url, init); - const text = await response.text(); - const payload = text ? JSON.parse(text) : null; - if (!response.ok) { - throw new Error(`Daemon request failed (${response.status}): ${text}`); - } - return payload as T; -} diff --git a/apps/debug-tui/src/e2e/full-loop.test.ts b/apps/debug-tui/src/e2e/full-loop.test.ts deleted file mode 100644 index ec15f220d..000000000 --- a/apps/debug-tui/src/e2e/full-loop.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; -import { parsePluginResponse } from "@plannotator/shared/plugin-protocol"; -import { getScenario } from "../scenarios"; -import { runScenario } from "../scenarios/run-scenario"; - -const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); - -describe.skipIf(process.env.PLANNOTATOR_AGENT_SIMULATOR_E2E !== "1")( - "agent simulator full process-to-daemon loop", - () => { - test( - "spawns the real Plannotator plugin command, creates a daemon plan session, and approves it", - async () => { - await buildFrontendShell(); - const result = await runScenario(getScenario("opencode-plan"), { - repoRoot, - timeoutMs: 120_000, - }); - - expect(result.session?.mode).toBe("plan"); - expect(result.process.exitCode).toBe(0); - const response = parsePluginResponse(result.process.stdout.trim()); - expect(response?.ok).toBe(true); - expect(response?.ok === true ? response.result : undefined).toMatchObject({ - approved: true, - }); - }, - 180_000, - ); - }, -); - -async function buildFrontendShell(): Promise { - const child = spawn("bun", ["run", "--cwd", "apps/debug-frontend", "build"], { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - let output = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - output += chunk; - }); - child.stderr.on("data", (chunk: string) => { - output += chunk; - }); - const code = await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", resolve); - }); - if (code !== 0) { - throw new Error(`frontend build failed:\n${output}`); - } -} diff --git a/apps/debug-tui/src/logging/run-log.ts b/apps/debug-tui/src/logging/run-log.ts deleted file mode 100644 index 885d39f2f..000000000 --- a/apps/debug-tui/src/logging/run-log.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { appendFile, mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { ScenarioRunLog, ScenarioRunResult } from "../scenarios/run-scenario"; -import type { ScenarioDefinition } from "../scenarios/types"; - -export interface SimulatorRunLog { - path: string; - latestPath: string; - append(entry: ScenarioRunLog): Promise; - appendText(text: string): Promise; - appendResult(result: ScenarioRunResult): Promise; -} - -export async function createSimulatorRunLog( - repoRoot: string, - scenario: ScenarioDefinition, -): Promise { - const dir = join(repoRoot, "plannotator-local", "simulator-runs"); - await mkdir(dir, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const path = join(dir, `${stamp}-${scenario.id}.log`); - const latestPath = join(dir, "latest.log"); - const header = [ - `scenario=${scenario.id}`, - `agent=${scenario.agent}`, - `kind=${scenario.kind}`, - `session=${scenario.expectedSessionMode}`, - "", - ].join("\n"); - await Promise.all([writeFile(path, header), writeFile(latestPath, header)]); - - const appendText = async (text: string) => { - await Promise.all([appendFile(path, text), appendFile(latestPath, text)]); - }; - - return { - path, - latestPath, - append(entry) { - return appendText(`${entry.at} ${entry.message}\n`); - }, - appendText, - appendResult(result) { - return appendText( - [ - "", - `exitCode=${result.process.exitCode}`, - `signal=${result.process.signal ?? ""}`, - `timedOut=${result.process.timedOut}`, - `session=${result.session?.url ?? ""}`, - "", - "stdout:", - result.process.stdout, - "", - "stderr:", - result.process.stderr, - "", - ].join("\n"), - ); - }, - }; -} diff --git a/apps/debug-tui/src/main.tsx b/apps/debug-tui/src/main.tsx deleted file mode 100644 index 008721438..000000000 --- a/apps/debug-tui/src/main.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createCliRenderer } from "@opentui/core"; -import { createRoot, useKeyboard, useRenderer } from "@opentui/react"; -import { useMemo, useState } from "react"; -import type React from "react"; -import { copyTextToClipboard } from "./clipboard"; -import { authTokenFromBrowserUrl, createSimulatorDaemonClient } from "./daemon/client"; -import { createSimulatorRunLog } from "./logging/run-log"; -import { scenarioDefinitions } from "./scenarios"; -import { runScenario, type ScenarioRunLog } from "./scenarios/run-scenario"; -import type { ScenarioDefinition } from "./scenarios/types"; - -const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); -const defaultLatestLogPath = join(repoRoot, "plannotator-local", "simulator-runs", "latest.log"); -const daemonBaseUrl = process.env.PLANNOTATOR_SIMULATOR_DAEMON_URL; -const daemonAuthToken = authTokenFromBrowserUrl(process.env.PLANNOTATOR_SIMULATOR_DAEMON_BROWSER_URL); - -function SimulatorApp() { - const renderer = useRenderer(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [runningIds, setRunningIds] = useState>(new Set()); - const [autoComplete, setAutoComplete] = useState(false); - const [logPath, setLogPath] = useState(defaultLatestLogPath); - const [copyStatus, setCopyStatus] = useState("c copies log, p copies log path"); - const [logs, setLogs] = useState([]); - const selected = scenarioDefinitions[selectedIndex] ?? scenarioDefinitions[0]; - - useKeyboard((key) => { - if (key.name === "q" || (key.ctrl && key.name === "c")) { - renderer.stop(); - return; - } - if (key.name === "c") { - void copyLogFile(logPath, setCopyStatus); - return; - } - if (key.name === "p") { - void copyPlainText(logPath, "Copied log path.", setCopyStatus); - return; - } - if (key.name === "m") { - setAutoComplete((value) => !value); - } - if (key.name === "up" || key.name === "k") { - setSelectedIndex((index) => Math.max(0, index - 1)); - } - if (key.name === "down" || key.name === "j") { - setSelectedIndex((index) => Math.min(scenarioDefinitions.length - 1, index + 1)); - } - if (key.name === "return") { - if (runningIds.has(selected.id)) return; - void runSelected(selected, { autoComplete, setRunningIds, setLogs, setLogPath }); - } - if (key.name === "a") { - const notRunning = scenarioDefinitions.filter((s) => !runningIds.has(s.id)); - for (const scenario of notRunning) { - void runSelected(scenario, { autoComplete, setRunningIds, setLogs, setLogPath }); - } - } - }); - - const recentLogs = useMemo(() => logs.slice(-16), [logs]); - - return ( - - Plannotator Agent Simulator - - Enter starts scenario. a starts all. m toggles manual/auto. c copies logs. q exits. - - - mode={autoComplete ? "auto-complete" : "manual"} running={runningIds.size} daemon= - {daemonBaseUrl ?? "discover"} - - - {copyStatus} - - - - {scenarioDefinitions.map((scenario, index) => ( - - ))} - - - {selected.title} - {selected.description} - - agent={selected.agent} kind={selected.kind} session={selected.expectedSessionMode} - - Logs ({logs.length}) - {recentLogs.map((log) => ( - - {formatLogTime(log.at)} {log.message} - - ))} - - - - ); -} - -function ScenarioRow({ - scenario, - selected, - running, -}: { - scenario: ScenarioDefinition; - selected: boolean; - running: boolean; -}) { - const marker = running ? ">" : selected ? "*" : " "; - const color = running ? "#fbbf24" : selected ? "#ffffff" : "#d1d5db"; - return ( - - {marker} {scenario.id} - - ); -} - -async function runSelected( - scenario: ScenarioDefinition, - options: { - autoComplete: boolean; - setRunningIds: React.Dispatch>>; - setLogs: React.Dispatch>; - setLogPath: (path: string) => void; - }, -): Promise { - options.setRunningIds((ids) => new Set([...ids, scenario.id])); - const runLog = await createSimulatorRunLog(repoRoot, scenario); - options.setLogPath(runLog.latestPath); - const queued = { at: new Date().toISOString(), message: `queued ${scenario.id}` }; - await runLog.append(queued); - options.setLogs((logs) => [...logs, queued]); - void forwardLogToDaemon(scenario.id, queued); - try { - const result = await runScenario(scenario, { - repoRoot, - completion: options.autoComplete ? "auto" : "manual", - useSharedDaemon: true, - stopDaemonOnFinish: false, - timeoutMs: 10 * 60_000, - daemonBaseUrl, - daemonAuthToken, - onLog: (entry) => { - void runLog.append(entry); - options.setLogs((logs) => [...logs, entry]); - }, - }); - await runLog.appendResult(result); - options.setLogs((logs) => [ - ...logs, - { - at: new Date().toISOString(), - message: `finished ${scenario.id} exit=${result.process.exitCode}`, - }, - ]); - } catch (err) { - const entry = { - at: new Date().toISOString(), - message: `[${scenario.id}] ${err instanceof Error ? err.message : "scenario failed"}`, - }; - await runLog.append(entry); - options.setLogs((logs) => [...logs, entry]); - } finally { - options.setRunningIds((ids) => { - const next = new Set(ids); - next.delete(scenario.id); - return next; - }); - } -} - -async function forwardLogToDaemon(scenarioId: string, entry: ScenarioRunLog): Promise { - if (!daemonBaseUrl) return; - try { - await createSimulatorDaemonClient(daemonBaseUrl, fetch, { authToken: daemonAuthToken }).postDebugLog({ - at: entry.at, - source: "agent-simulator", - scenarioId, - message: entry.message, - level: "info", - }); - } catch { - // The local run log remains the source of truth if debug forwarding is unavailable. - } -} - -async function copyLogFile( - path: string, - setCopyStatus: (message: string) => void, -): Promise { - try { - const text = await readFile(path, "utf8"); - await copyPlainText(text, "Copied latest log.", setCopyStatus); - } catch (err) { - setCopyStatus(err instanceof Error ? `Copy failed: ${err.message}` : "Copy failed."); - } -} - -async function copyPlainText( - text: string, - successMessage: string, - setCopyStatus: (message: string) => void, -): Promise { - const result = await copyTextToClipboard(text); - setCopyStatus(result.ok ? successMessage : result.message); -} - -function formatLogTime(iso: string): string { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return iso; - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -if (import.meta.main) { - const renderer = await createCliRenderer({ exitOnCtrlC: true }); - createRoot(renderer).render(); -} diff --git a/apps/debug-tui/src/process/run-plannotator.test.ts b/apps/debug-tui/src/process/run-plannotator.test.ts deleted file mode 100644 index 1cfdce2f0..000000000 --- a/apps/debug-tui/src/process/run-plannotator.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "vitest"; -import { runPlannotatorCommand } from "./run-plannotator"; -import { parseSessionReadyLine } from "./session-ready"; - -describe("Plannotator process runner", () => { - test("parses PLANNOTATOR_SESSION_READY from stderr", () => { - const session = parseSessionReadyLine( - 'PLANNOTATOR_SESSION_READY {"mode":"plan","url":"http://127.0.0.1:19432/s/a","port":19432,"isRemote":false}', - ); - - expect(session?.mode).toBe("plan"); - expect(session?.url).toContain("/s/a"); - }); - - test("captures stdout, stderr, exit status, and session readiness", async () => { - const dir = await mkdtemp(join(tmpdir(), "plannotator-process-")); - const script = join(dir, "child.ts"); - await writeFile( - script, - ` -console.error('progress line'); -console.error('PLANNOTATOR_SESSION_READY {"mode":"review","url":"http://127.0.0.1:19432/s/review","port":19432,"isRemote":false}'); -console.log('{"ok":true}'); -`, - ); - - const result = await runPlannotatorCommand({ - command: process.execPath, - args: [script], - cwd: dir, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('"ok":true'); - expect(result.stderr).toContain("progress line"); - expect(result.session?.mode).toBe("review"); - }); -}); diff --git a/apps/debug-tui/src/process/run-plannotator.ts b/apps/debug-tui/src/process/run-plannotator.ts deleted file mode 100644 index 249e4cee8..000000000 --- a/apps/debug-tui/src/process/run-plannotator.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { spawn } from "node:child_process"; -import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; -import type { ScenarioCommand } from "../scenarios/types"; -import { parseSessionReadyLine } from "./session-ready"; - -export interface ProcessRunResult { - command: ScenarioCommand; - stdout: string; - stderr: string; - exitCode: number | null; - signal: NodeJS.Signals | null; - timedOut: boolean; - session?: PluginSessionInfo; -} - -export interface ProcessRunOptions { - timeoutMs?: number; - onSessionReady?: (session: PluginSessionInfo) => void; - onLog?: (line: string) => void; -} - -export function runPlannotatorCommand( - command: ScenarioCommand, - options: ProcessRunOptions = {}, -): Promise { - const timeoutMs = options.timeoutMs ?? 120_000; - const child = spawn(command.command, command.args, { - cwd: command.cwd, - env: { ...process.env, ...command.env }, - stdio: ["pipe", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let pendingStderr = ""; - let session: PluginSessionInfo | undefined; - let timedOut = false; - - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - const killTimer = setTimeout(() => child.kill("SIGKILL"), 1_000); - killTimer.unref?.(); - }, timeoutMs); - timeout.unref?.(); - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - pendingStderr += chunk; - const lines = pendingStderr.split(/\r?\n/); - pendingStderr = lines.pop() ?? ""; - for (const line of lines) { - if (!line) continue; - options.onLog?.(line); - const ready = parseSessionReadyLine(line); - if (ready && !session) { - session = ready; - options.onSessionReady?.(ready); - } - } - }); - - child.stdin.end(command.stdin ?? ""); - - return new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", (exitCode, signal) => { - clearTimeout(timeout); - if (pendingStderr) { - options.onLog?.(pendingStderr); - const ready = parseSessionReadyLine(pendingStderr); - if (ready && !session) session = ready; - } - resolve({ - command, - stdout, - stderr, - exitCode, - signal, - timedOut, - ...(session && { session }), - }); - }); - }); -} diff --git a/apps/debug-tui/src/process/session-ready.ts b/apps/debug-tui/src/process/session-ready.ts deleted file mode 100644 index b9c545aeb..000000000 --- a/apps/debug-tui/src/process/session-ready.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; - -const SESSION_READY_PREFIX = "PLANNOTATOR_SESSION_READY "; - -export function parseSessionReadyLine(line: string): PluginSessionInfo | null { - const index = line.indexOf(SESSION_READY_PREFIX); - if (index === -1) return null; - - try { - const value = JSON.parse(line.slice(index + SESSION_READY_PREFIX.length)); - if (!isSessionInfo(value)) return null; - return value; - } catch { - return null; - } -} - -function isSessionInfo(value: unknown): value is PluginSessionInfo { - if (!value || typeof value !== "object") return false; - const session = value as Partial; - return ( - (session.mode === "plan" || - session.mode === "review" || - session.mode === "annotate" || - session.mode === "archive") && - typeof session.url === "string" && - typeof session.port === "number" && - typeof session.isRemote === "boolean" - ); -} diff --git a/apps/debug-tui/src/scenarios/fixtures.ts b/apps/debug-tui/src/scenarios/fixtures.ts deleted file mode 100644 index 3c8ceae7f..000000000 --- a/apps/debug-tui/src/scenarios/fixtures.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - -export interface WorkspaceFixture { - root: string; - markdownPath: string; - htmlPath: string; - planPath: string; - archivePath: string; - interviewBundlePath: string; - factsBundlePath: string; - codexHome: string; - copilotHome: string; - geminiTranscriptPath: string; - geminiPlanFilename: string; - geminiSessionId: string; - cleanup(): Promise; -} - -const PLAN_MARKDOWN = `# Simulator plan - -This fixture proves that an agent protocol can create a daemon-backed Plannotator plan session. -`; - -export async function createWorkspaceFixture(label: string): Promise { - const root = await mkdtemp(join(tmpdir(), `plannotator-${label}-`)); - const markdownPath = join(root, "docs", "notes.md"); - const htmlPath = join(root, "docs", "page.html"); - const planPath = join(root, "plan.md"); - const archivePath = join(root, "plans"); - const codexHome = join(root, "codex-home"); - const copilotHome = join(root, "copilot-home"); - const geminiSessionId = "session-simulator"; - const geminiPlanFilename = "simulator-plan.md"; - const geminiTranscriptPath = join(root, "gemini", "chats", "session-1.json"); - - const goalsDir = join(root, "goals"); - const interviewBundlePath = join(goalsDir, "interview.json"); - const factsBundlePath = join(goalsDir, "facts.json"); - - await mkdir(join(root, "docs"), { recursive: true }); - await mkdir(archivePath, { recursive: true }); - await mkdir(goalsDir, { recursive: true }); - await writeFile(markdownPath, "# Notes\n\nAnnotate this markdown fixture.\n"); - await writeFile(htmlPath, "

    Fixture

    Annotate this HTML fixture.

    \n"); - await writeFile(planPath, PLAN_MARKDOWN); - await writeFile(join(archivePath, "approved-plan.md"), "# Approved fixture\n\nArchived plan.\n"); - await writeFile(interviewBundlePath, JSON.stringify({ - stage: "interview", - title: "Simulator goal setup", - goalSlug: "simulator-goal", - questions: [ - { id: "scope", prompt: "What is the scope of this goal?", answerMode: "text" }, - { id: "priority", prompt: "What priority is this?", answerMode: "single", options: [{ id: "high", label: "High" }, { id: "medium", label: "Medium" }, { id: "low", label: "Low" }], recommendedOptionIds: ["medium"] }, - ], - })); - await writeFile(factsBundlePath, JSON.stringify({ - stage: "facts", - title: "Simulator facts review", - goalSlug: "simulator-goal", - facts: [ - { id: "f1", text: "The daemon manages session lifecycle.", accepted: false, removed: false, automatedVerification: false }, - { id: "f2", text: "The CLI routes through the daemon.", accepted: true, removed: false, automatedVerification: true }, - ], - })); - - await createCodexFixture(codexHome); - await createCopilotFixture(copilotHome, root); - await createGeminiFixture({ - transcriptPath: geminiTranscriptPath, - sessionId: geminiSessionId, - planFilename: geminiPlanFilename, - }); - await createGitFixture(root); - - return { - root, - markdownPath, - htmlPath, - planPath, - archivePath, - interviewBundlePath, - factsBundlePath, - codexHome, - copilotHome, - geminiTranscriptPath, - geminiPlanFilename, - geminiSessionId, - cleanup: () => rm(root, { force: true, recursive: true }), - }; -} - -async function createCodexFixture(codexHome: string): Promise { - const threadId = "11111111-1111-4111-8111-111111111111"; - const rolloutDir = join(codexHome, ".codex", "sessions", "2026", "05", "17"); - await mkdir(rolloutDir, { recursive: true }); - const rolloutPath = join(rolloutDir, `rollout-2026-05-17T00-00-00-${threadId}.jsonl`); - const rows = [ - { - type: "event_msg", - payload: { - type: "item_completed", - turn_id: "turn-1", - item: { type: "Plan", text: PLAN_MARKDOWN }, - }, - }, - ]; - await writeFile(rolloutPath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`); -} - -async function createCopilotFixture(copilotHome: string, cwd: string): Promise { - const sessionId = "22222222-2222-4222-8222-222222222222"; - const sessionDir = join(copilotHome, "session-state", sessionId); - await mkdir(sessionDir, { recursive: true }); - await writeFile(join(sessionDir, "workspace.yaml"), `cwd: ${cwd}\n`); - await writeFile(join(sessionDir, "plan.md"), PLAN_MARKDOWN); -} - -async function createGeminiFixture({ - transcriptPath, - sessionId, - planFilename, -}: { - transcriptPath: string; - sessionId: string; - planFilename: string; -}): Promise { - const projectTempDir = dirname(dirname(transcriptPath)); - const planDir = join(projectTempDir, sessionId, "plans"); - await mkdir(planDir, { recursive: true }); - await mkdir(join(projectTempDir, "chats"), { recursive: true }); - await writeFile(transcriptPath, "{}\n"); - await writeFile(join(planDir, planFilename), PLAN_MARKDOWN); -} - -async function createGitFixture(cwd: string): Promise { - runGit(cwd, ["init"]); - runGit(cwd, ["config", "user.email", "simulator@example.com"]); - runGit(cwd, ["config", "user.name", "Plannotator Simulator"]); - runGit(cwd, ["add", "."]); - runGit(cwd, ["commit", "-m", "Initial simulator fixture"]); - await writeFile(join(cwd, "docs", "notes.md"), "# Notes\n\nChanged by simulator fixture.\n"); -} - -function runGit(cwd: string, args: string[]): void { - const result = spawnSync("git", args, { cwd, stdio: "ignore" }); - if (result.status !== 0) { - throw new Error(`git ${args.join(" ")} failed while preparing simulator fixture`); - } -} - -export function codexThreadId(): string { - return "11111111-1111-4111-8111-111111111111"; -} - -export function copilotSessionId(): string { - return "22222222-2222-4222-8222-222222222222"; -} - -export function simulatorPlan(): string { - return PLAN_MARKDOWN; -} diff --git a/apps/debug-tui/src/scenarios/index.test.ts b/apps/debug-tui/src/scenarios/index.test.ts deleted file mode 100644 index 59304d8d0..000000000 --- a/apps/debug-tui/src/scenarios/index.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { scenarioDefinitions, getScenario } from "./index"; -import type { ScenarioId } from "./types"; - -const requiredScenarioIds: ScenarioId[] = [ - "claude-plan-hook", - "opencode-plan", - "opencode-review", - "opencode-annotate", - "opencode-archive", - "pi-plan", - "pi-review", - "pi-annotate", - "pi-archive", - "codex-plan-hook", - "copilot-plan-hook", - "gemini-plan-file-hook", - "cli-review", - "cli-annotate", - "cli-annotate-gate", - "cli-annotate-html", - "cli-annotate-url", - "cli-archive", - "cli-setup-goal-interview", - "cli-setup-goal-facts", -]; - -describe("agent simulator scenarios", () => { - test("covers every accepted fixture-backed scenario", () => { - expect(scenarioDefinitions.map((scenario) => scenario.id).sort()).toEqual( - [...requiredScenarioIds].sort(), - ); - }); - - test("every scenario declares protocol metadata and completion mode", () => { - for (const scenario of scenarioDefinitions) { - expect(scenario.title).not.toBe(""); - expect(["hook", "plugin", "cli"]).toContain(scenario.kind); - expect(["plan", "review", "annotate", "archive", "goal-setup"]).toContain(scenario.expectedSessionMode); - } - }); - - test("every scenario builds a runnable command with isolated local fixtures", async () => { - for (const scenario of scenarioDefinitions) { - const fixture = await scenario.buildFixture("/repo"); - try { - const command = scenario.buildCommand("/repo", fixture); - expect(command.command).toBe("/repo/bin/plannotator.js"); - expect(command.cwd).toBe(fixture.cwd); - expect(command.env?.PLANNOTATOR_SHARE).toBe("disabled"); - expect(command.env?.PLANNOTATOR_DISABLE_AUTO_INSTALL).toBe("1"); - expect(fixture.cleanup).toEqual(expect.any(Function)); - } finally { - await fixture.cleanup?.(); - } - } - }); - - test("builds plugin commands with realistic stdin", async () => { - const scenario = getScenario("opencode-plan"); - const fixture = await scenario.buildFixture("/repo"); - try { - const command = scenario.buildCommand("/repo", fixture); - expect(command.args).toEqual(["plugin", "plan", "--origin", "opencode"]); - expect(JSON.parse(command.stdin ?? "{}")).toMatchObject({ - origin: "opencode", - plan: expect.stringContaining("Simulator plan"), - }); - } finally { - await fixture.cleanup?.(); - } - }); -}); diff --git a/apps/debug-tui/src/scenarios/index.ts b/apps/debug-tui/src/scenarios/index.ts deleted file mode 100644 index c0529eb05..000000000 --- a/apps/debug-tui/src/scenarios/index.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { join } from "node:path"; -import type { PluginRequest } from "@plannotator/shared/plugin-protocol"; -import { - codexThreadId, - copilotSessionId, - createWorkspaceFixture, - simulatorPlan, -} from "./fixtures"; -import type { - CompletionMode, - ScenarioCommand, - ScenarioDefinition, - ScenarioFixture, - ScenarioId, -} from "./types"; - -const DEFAULT_TIMEOUT_MS = 10 * 60_000; - -function plannotatorCommand(repoRoot: string): string { - return join(repoRoot, "bin", "plannotator.js"); -} - -function commonEnv(fixture: ScenarioFixture): Record { - return { - PLANNOTATOR_BROWSER: browserNoopCommand(), - PLANNOTATOR_DISABLE_AUTO_INSTALL: "1", - PLANNOTATOR_SHARE: "disabled", - ...(fixture.env ?? {}), - }; -} - -function browserNoopCommand(): string { - return process.platform === "win32" ? "cmd" : "/usr/bin/true"; -} - -function command( - repoRoot: string, - fixture: ScenarioFixture, - args: string[], - stdin = fixture.stdin, -): ScenarioCommand { - return { - command: plannotatorCommand(repoRoot), - args, - stdin, - cwd: fixture.cwd, - env: commonEnv(fixture), - }; -} - -async function workspace(label: string): Promise { - const fixture = await createWorkspaceFixture(label); - return { - cwd: fixture.root, - cleanup: fixture.cleanup, - env: { - HOME: fixture.root, - COPILOT_HOME: fixture.copilotHome, - }, - }; -} - -async function pluginFixture(label: string, request: PluginRequest): Promise { - const fixture = await createWorkspaceFixture(label); - return { - cwd: fixture.root, - stdin: JSON.stringify({ ...request, cwd: fixture.root, timeoutMs: DEFAULT_TIMEOUT_MS }), - completion: modeForPluginAction(request.action), - cleanup: fixture.cleanup, - env: { - HOME: fixture.root, - COPILOT_HOME: fixture.copilotHome, - }, - }; -} - -function modeForPluginAction(action: PluginRequest["action"]): CompletionMode { - if (action === "annotate-last") return "annotate"; - return action; -} - -function pluginScenario({ - id, - title, - origin, - action, - request, -}: { - id: ScenarioId; - title: string; - origin: "opencode" | "pi"; - action: PluginRequest["action"]; - request: Record; -}): ScenarioDefinition { - const expectedSessionMode = modeForPluginAction(action); - return { - id, - title, - kind: "plugin", - agent: origin, - expectedSessionMode, - description: `Runs plannotator plugin ${action} using the ${origin} protocol.`, - buildFixture: (repoRoot) => - pluginFixture(id, { - ...request, - action, - origin, - sharingEnabled: false, - } as PluginRequest), - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["plugin", action, "--origin", origin]), - }; -} - -export const scenarioDefinitions: ScenarioDefinition[] = [ - { - id: "claude-plan-hook", - title: "Claude Code plan hook", - kind: "hook", - agent: "claude-code", - expectedSessionMode: "plan", - description: "Feeds a Claude PermissionRequest ExitPlanMode event through stdin.", - async buildFixture() { - const fixture = await workspace("claude-plan"); - fixture.stdin = JSON.stringify({ - hook_event_name: "PermissionRequest", - tool_name: "ExitPlanMode", - tool_input: { plan: simulatorPlan() }, - permission_mode: "default", - }); - fixture.completion = "plan"; - return fixture; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), - }, - pluginScenario({ - id: "opencode-plan", - title: "OpenCode plan", - origin: "opencode", - action: "plan", - request: { plan: simulatorPlan() }, - }), - pluginScenario({ - id: "opencode-review", - title: "OpenCode review", - origin: "opencode", - action: "review", - request: { args: "" }, - }), - pluginScenario({ - id: "opencode-annotate", - title: "OpenCode annotate", - origin: "opencode", - action: "annotate", - request: { markdown: "# Annotate\n\nOpenCode annotation fixture.", filePath: "opencode.md" }, - }), - pluginScenario({ - id: "opencode-archive", - title: "OpenCode archive", - origin: "opencode", - action: "archive", - request: {}, - }), - pluginScenario({ - id: "pi-plan", - title: "Pi plan", - origin: "pi", - action: "plan", - request: { plan: simulatorPlan() }, - }), - pluginScenario({ - id: "pi-review", - title: "Pi review", - origin: "pi", - action: "review", - request: { args: "" }, - }), - pluginScenario({ - id: "pi-annotate", - title: "Pi annotate", - origin: "pi", - action: "annotate", - request: { markdown: "# Annotate\n\nPi annotation fixture.", filePath: "pi.md" }, - }), - pluginScenario({ - id: "pi-archive", - title: "Pi archive", - origin: "pi", - action: "archive", - request: {}, - }), - { - id: "codex-plan-hook", - title: "Codex Stop hook plan", - kind: "hook", - agent: "codex", - expectedSessionMode: "plan", - description: "Feeds a Codex Stop hook and resolves the plan from a fixture rollout file.", - async buildFixture() { - const fixture = await createWorkspaceFixture("codex-plan"); - return { - cwd: fixture.root, - stdin: JSON.stringify({ - hook_event_name: "Stop", - turn_id: "turn-1", - stop_hook_active: true, - }), - completion: "plan", - cleanup: fixture.cleanup, - env: { - HOME: fixture.codexHome, - CODEX_THREAD_ID: codexThreadId(), - }, - }; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), - }, - { - id: "copilot-plan-hook", - title: "Copilot plan hook", - kind: "hook", - agent: "copilot-cli", - expectedSessionMode: "plan", - description: "Runs the Copilot pre-tool hook against a local session-state fixture.", - async buildFixture() { - const fixture = await createWorkspaceFixture("copilot-plan"); - return { - cwd: fixture.root, - stdin: JSON.stringify({ - toolName: "exit_plan_mode", - toolArgs: "{}", - cwd: fixture.root, - timestamp: Date.now(), - sessionId: copilotSessionId(), - }), - completion: "plan", - cleanup: fixture.cleanup, - env: { - HOME: fixture.root, - COPILOT_HOME: fixture.copilotHome, - COPILOT_CLI: "1", - }, - }; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["copilot-plan"]), - }, - { - id: "gemini-plan-file-hook", - title: "Gemini plan-file hook", - kind: "hook", - agent: "gemini-cli", - expectedSessionMode: "plan", - description: "Feeds a Gemini plan-file event and reads plan markdown from disk.", - async buildFixture() { - const fixture = await createWorkspaceFixture("gemini-plan"); - return { - cwd: fixture.root, - stdin: JSON.stringify({ - hook_event_name: "PermissionRequest", - transcript_path: fixture.geminiTranscriptPath, - session_id: fixture.geminiSessionId, - tool_input: { plan_filename: fixture.geminiPlanFilename }, - }), - completion: "plan", - cleanup: fixture.cleanup, - env: { - HOME: fixture.root, - GEMINI_CLI: "1", - }, - }; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, []), - }, - { - id: "cli-review", - title: "Direct CLI review", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "review", - description: "Runs plannotator review against a local git diff fixture.", - buildFixture: async () => { - const fixture = await workspace("cli-review"); - fixture.completion = "review"; - return fixture; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["review"]), - }, - { - id: "cli-annotate", - title: "Direct CLI annotate", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "annotate", - description: "Runs plannotator annotate against a markdown fixture.", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-annotate"); - return { - cwd: fixture.root, - completion: "annotate", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["annotate", "docs/notes.md", "--json"]), - }, - { - id: "cli-annotate-gate", - title: "CLI annotate with gate", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "annotate", - description: "Runs plannotator annotate --gate, adding an Approve button alongside annotations.", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-annotate-gate"); - return { - cwd: fixture.root, - completion: "annotate", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["annotate", "docs/notes.md", "--gate", "--json"]), - }, - { - id: "cli-annotate-html", - title: "CLI annotate --render-html", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "annotate", - description: "Runs plannotator annotate on an HTML file with --render-html (rendered as-is, not converted).", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-annotate-html"); - return { - cwd: fixture.root, - completion: "annotate", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["annotate", "docs/page.html", "--render-html", "--json"]), - }, - { - id: "cli-annotate-url", - title: "CLI annotate URL (--no-jina)", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "annotate", - description: "Runs plannotator annotate on a URL with Jina disabled (uses fetch+Turndown fallback).", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-annotate-url"); - return { - cwd: fixture.root, - completion: "annotate", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["annotate", "https://example.com", "--no-jina", "--json"]), - }, - { - id: "cli-archive", - title: "Direct CLI archive", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "archive", - description: "Runs plannotator archive and closes it through the session API.", - buildFixture: async () => { - const fixture = await workspace("cli-archive"); - fixture.completion = "archive"; - return fixture; - }, - buildCommand: (repoRoot, fixture) => command(repoRoot, fixture, ["archive"]), - }, - { - id: "cli-setup-goal-interview", - title: "CLI setup-goal interview", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "goal-setup", - description: "Runs plannotator setup-goal interview with a question bundle fixture.", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-setup-goal-interview"); - return { - cwd: fixture.root, - completion: "goal-setup", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["setup-goal", "interview", "goals/interview.json", "--json"]), - }, - { - id: "cli-setup-goal-facts", - title: "CLI setup-goal facts", - kind: "cli", - agent: "direct-cli", - expectedSessionMode: "goal-setup", - description: "Runs plannotator setup-goal facts with a facts review bundle fixture.", - async buildFixture() { - const fixture = await createWorkspaceFixture("cli-setup-goal-facts"); - return { - cwd: fixture.root, - completion: "goal-setup", - cleanup: fixture.cleanup, - env: { HOME: fixture.root }, - }; - }, - buildCommand: (repoRoot, fixture) => - command(repoRoot, fixture, ["setup-goal", "facts", "goals/facts.json", "--json"]), - }, -]; - -export function getScenario(id: ScenarioId): ScenarioDefinition { - const scenario = scenarioDefinitions.find((item) => item.id === id); - if (!scenario) throw new Error(`Unknown simulator scenario: ${id}`); - return scenario; -} diff --git a/apps/debug-tui/src/scenarios/run-scenario.ts b/apps/debug-tui/src/scenarios/run-scenario.ts deleted file mode 100644 index 0cb2ebd47..000000000 --- a/apps/debug-tui/src/scenarios/run-scenario.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { PluginSessionInfo } from "@plannotator/shared/plugin-protocol"; -import { completeSession, createSimulatorDaemonClient } from "../daemon/client"; -import { runPlannotatorCommand, type ProcessRunResult } from "../process/run-plannotator"; -import type { ScenarioDefinition, ScenarioFixture } from "./types"; - -export interface ScenarioRunLog { - at: string; - message: string; -} - -export interface ScenarioRunResult { - scenario: ScenarioDefinition; - fixture: ScenarioFixture; - process: ProcessRunResult; - session?: PluginSessionInfo; - daemonSessionsSeen: number; - logs: ScenarioRunLog[]; -} - -export interface ScenarioRunOptions { - repoRoot: string; - timeoutMs?: number; - fetch?: typeof fetch; - onLog?: (entry: ScenarioRunLog) => void; - completion?: "auto" | "manual"; - useSharedDaemon?: boolean; - stopDaemonOnFinish?: boolean; - daemonBaseUrl?: string; - daemonAuthToken?: string; -} - -export async function runScenario( - scenario: ScenarioDefinition, - options: ScenarioRunOptions, -): Promise { - const logs: ScenarioRunLog[] = []; - const fetchImpl = options.fetch ?? fetch; - const log = (message: string) => { - const entry = { at: new Date().toISOString(), message }; - logs.push(entry); - options.onLog?.(entry); - void publishDebugLog({ - baseUrl: options.daemonBaseUrl, - daemonAuthToken: options.daemonAuthToken, - fetch: fetchImpl, - scenarioId: scenario.id, - entry, - }); - }; - - await assertFrontendShellBuilt(options.repoRoot); - const fixture = await scenario.buildFixture(options.repoRoot); - const command = scenario.buildCommand(options.repoRoot, fixture); - if (options.useSharedDaemon && scenario.agent !== "codex") { - command.env = { ...(command.env ?? {}) }; - delete command.env.HOME; - } - let sessionFromStderr: PluginSessionInfo | undefined; - const shouldStopDaemon = options.stopDaemonOnFinish ?? !options.useSharedDaemon; - - try { - log(`starting ${scenario.id}`); - const processPromise = runPlannotatorCommand(command, { - timeoutMs: options.timeoutMs, - onLog: (line) => log(line), - onSessionReady: (session) => { - sessionFromStderr = session; - log(`session ready ${session.url}`); - }, - }); - - const session = await waitForScenarioSession({ - fixture, - expectedMode: scenario.expectedSessionMode, - sessionFromStderr: () => sessionFromStderr, - fetch: fetchImpl, - timeoutMs: options.timeoutMs ?? 120_000, - daemonBaseUrl: options.daemonBaseUrl, - daemonAuthToken: options.daemonAuthToken, - }); - if ((options.completion ?? "auto") === "auto") { - log(`completing ${session.mode} session ${session.url}`); - await completeSession(fetchImpl, session.url, scenario.expectedSessionMode); - } else { - log(`waiting for browser action at ${session.url}`); - } - - const process = await processPromise; - return { - scenario, - fixture, - process, - session, - daemonSessionsSeen: session.daemonSessionsSeen, - logs, - }; - } finally { - if (shouldStopDaemon) { - await shutdownDaemon(fixture, fetchImpl); - } - await fixture.cleanup?.(); - } -} - -async function assertFrontendShellBuilt(repoRoot: string): Promise { - try { - await access(join(repoRoot, "apps", "debug-frontend", "dist", "index.html")); - } catch { - throw new Error("Debug frontend shell is not built. Run `bun run build:debug-frontend` first."); - } -} - -async function waitForScenarioSession({ - fixture, - expectedMode, - sessionFromStderr, - fetch, - timeoutMs, - daemonBaseUrl, - daemonAuthToken, -}: { - fixture: ScenarioFixture; - expectedMode: string; - sessionFromStderr: () => PluginSessionInfo | undefined; - fetch: typeof globalThis.fetch; - timeoutMs: number; - daemonBaseUrl?: string; - daemonAuthToken?: string; -}): Promise { - const deadline = Date.now() + timeoutMs; - let daemonSessionsSeen = 0; - while (Date.now() < deadline) { - const ready = sessionFromStderr(); - const state = await readDaemonState(fixture); - const baseUrl = daemonBaseUrl ?? state?.baseUrl ?? (ready ? new URL(ready.url).origin : undefined); - const authToken = daemonAuthToken ?? state?.authToken; - if (baseUrl) { - const client = createSimulatorDaemonClient(baseUrl, fetch, { authToken }); - try { - const sessions = await client.listSessions(); - daemonSessionsSeen = Math.max(daemonSessionsSeen, sessions.length); - const session = sessions.find( - (item) => item.mode === expectedMode && (!ready || item.url === ready.url), - ); - if (session) { - const sessionUrl = session.url; - const url = new URL(sessionUrl); - return { - mode: expectedMode as PluginSessionInfo["mode"], - url: sessionUrl, - port: Number(url.port), - isRemote: ready?.isRemote ?? state?.isRemote === true, - daemonSessionsSeen, - }; - } - } catch { - // The daemon state can appear before the server is ready. Keep polling. - } - } - - await sleep(100); - } - throw new Error(`Timed out waiting for ${expectedMode} session.`); -} - -async function publishDebugLog({ - baseUrl, - daemonAuthToken, - fetch, - scenarioId, - entry, -}: { - baseUrl?: string; - daemonAuthToken?: string; - fetch: typeof globalThis.fetch; - scenarioId: string; - entry: ScenarioRunLog; -}): Promise { - if (!baseUrl) return; - try { - await createSimulatorDaemonClient(baseUrl, fetch, { authToken: daemonAuthToken }).postDebugLog({ - at: entry.at, - source: "agent-simulator", - scenarioId, - message: entry.message, - level: "info", - }); - } catch { - // Debug forwarding must never change scenario behavior. - } -} - -async function readDaemonState(fixture: ScenarioFixture): Promise<{ baseUrl?: string; isRemote?: boolean; authToken?: string } | null> { - const home = fixture.env?.HOME ?? process.env.HOME; - if (!home) return null; - try { - return JSON.parse(await readFile(join(home, ".plannotator", "daemon.json"), "utf8")) as { - baseUrl?: string; - isRemote?: boolean; - authToken?: string; - }; - } catch { - return null; - } -} - -async function shutdownDaemon(fixture: ScenarioFixture, fetchImpl: typeof fetch): Promise { - const state = await readDaemonState(fixture); - if (!state?.baseUrl) return; - try { - await createSimulatorDaemonClient(state.baseUrl, fetchImpl, { authToken: state.authToken }).shutdown(); - } catch { - // Best-effort test cleanup; failed scenario output still contains process logs. - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/apps/debug-tui/src/scenarios/types.ts b/apps/debug-tui/src/scenarios/types.ts deleted file mode 100644 index 5431140ac..000000000 --- a/apps/debug-tui/src/scenarios/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { PluginRequest } from "@plannotator/shared/plugin-protocol"; - -export type ScenarioId = - | "claude-plan-hook" - | "opencode-plan" - | "opencode-review" - | "opencode-annotate" - | "opencode-archive" - | "pi-plan" - | "pi-review" - | "pi-annotate" - | "pi-archive" - | "codex-plan-hook" - | "copilot-plan-hook" - | "gemini-plan-file-hook" - | "cli-review" - | "cli-annotate" - | "cli-annotate-gate" - | "cli-annotate-html" - | "cli-annotate-url" - | "cli-archive" - | "cli-setup-goal-interview" - | "cli-setup-goal-facts"; - -export type ScenarioKind = "hook" | "plugin" | "cli"; -export type CompletionMode = "plan" | "review" | "annotate" | "archive" | "goal-setup"; - -export interface ScenarioFixture { - cwd: string; - stdin?: string; - env?: Record; - cleanup?: () => Promise | void; - completion?: CompletionMode; -} - -export interface ScenarioCommand { - command: string; - args: string[]; - stdin?: string; - cwd: string; - env?: Record; -} - -export interface ScenarioDefinition { - id: ScenarioId; - title: string; - kind: ScenarioKind; - agent: "claude-code" | "opencode" | "pi" | "codex" | "copilot-cli" | "gemini-cli" | "direct-cli"; - description: string; - expectedSessionMode: CompletionMode; - buildFixture(repoRoot: string): Promise; - buildCommand(repoRoot: string, fixture: ScenarioFixture): ScenarioCommand; -} - -export interface PluginScenarioOptions { - origin: "opencode" | "pi"; - action: PluginRequest["action"]; - request: PluginRequest; -} diff --git a/apps/debug-tui/tsconfig.json b/apps/debug-tui/tsconfig.json deleted file mode 100644 index 50529f270..000000000 --- a/apps/debug-tui/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM"], - "moduleResolution": "bundler", - "jsx": "react-jsx", - "jsxImportSource": "@opentui/react", - "strict": true, - "skipLibCheck": true, - "moduleDetection": "force", - "isolatedModules": true, - "allowImportingTsExtensions": true, - "noEmit": true, - "types": ["node", "vitest/globals"] - }, - "include": ["src"] -} diff --git a/apps/debug-tui/vitest.config.ts b/apps/debug-tui/vitest.config.ts deleted file mode 100644 index 840e944d9..000000000 --- a/apps/debug-tui/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], - }, -}); diff --git a/apps/debug-frontend/.oxfmtignore b/apps/frontend/.oxfmtignore similarity index 100% rename from apps/debug-frontend/.oxfmtignore rename to apps/frontend/.oxfmtignore diff --git a/apps/debug-frontend/README.md b/apps/frontend/README.md similarity index 100% rename from apps/debug-frontend/README.md rename to apps/frontend/README.md 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/debug-frontend/package.json b/apps/frontend/package.json similarity index 56% rename from apps/debug-frontend/package.json rename to apps/frontend/package.json index 2bb66326e..061a8540a 100644 --- a/apps/debug-frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,5 @@ { - "name": "@plannotator/debug-frontend", - "description": "Debug/development harness UI for the Plannotator daemon runtime. Not production code.", + "name": "@plannotator/frontend", "private": true, "version": "0.0.1", "type": "module", @@ -12,17 +11,37 @@ "lint:fix": "oxlint . --fix", "fmt": "oxfmt --ignore-path .oxfmtignore --write .", "fmt:check": "oxfmt --ignore-path .oxfmtignore --check .", - "test": "vitest run", - "test:browser": "vitest run --config vitest.browser.config.ts", + "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": { @@ -32,7 +51,6 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", - "@vitest/browser-playwright": "^4.0.16", "oxfmt": "^0.17.0", "oxlint": "^1.31.0", "tailwindcss": "^4.1.18", diff --git a/apps/debug-frontend/scripts/verify-single-file-build.ts b/apps/frontend/scripts/verify-single-file-build.ts similarity index 100% rename from apps/debug-frontend/scripts/verify-single-file-build.ts rename to apps/frontend/scripts/verify-single-file-build.ts 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/debug-frontend/src/app/router.tsx b/apps/frontend/src/app/router.tsx similarity index 91% rename from apps/debug-frontend/src/app/router.tsx rename to apps/frontend/src/app/router.tsx index 63b8e47d0..3693e34c7 100644 --- a/apps/debug-frontend/src/app/router.tsx +++ b/apps/frontend/src/app/router.tsx @@ -13,6 +13,8 @@ export function createAppRouter( routeTree, context, defaultPreload: "intent", + defaultPendingMs: 0, + defaultPendingMinMs: 0, }); } 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 4115ce65f..2eee5ec7c 100644 --- a/package.json +++ b/package.json @@ -22,24 +22,20 @@ "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:debug-frontend": "bun run --cwd apps/debug-frontend dev", - "dev:debug-tui": "bun run --cwd apps/debug-tui start", + "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", "build:opencode": "bun run --cwd apps/opencode-plugin build", "build:review": "bun run --cwd apps/review build", - "build:debug-frontend": "bun run --cwd apps/debug-frontend build", - "check:debug-frontend": "bun run --cwd apps/debug-frontend check", - "check:debug-tui": "bun run --cwd apps/debug-tui check", "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", "build": "bun run build:hook && bun run build:opencode", "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bash apps/pi-extension/vendor.sh && bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/debug-frontend/tsconfig.json && tsc --noEmit -p apps/debug-tui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", 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 ( +
    +
    +