From 5e49b975429cac58cee67c1a4d0f4af7f511806d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 20 May 2026 23:34:56 -0700 Subject: [PATCH 1/4] Add Ask AI to plan and annotate reviews - mount shared AI runtime on plan, annotate, review, and Pi servers - add shared document chat UI and comment-popover Ask AI entry point - default providers from detected agent origin and document first-run announcement - reuse shared code-review AI hook and document setup/docs --- AGENTS.md | 29 +- README.md | 9 +- .../src/content/docs/guides/ai-features.md | 10 +- apps/pi-extension/server.test.ts | 16 +- apps/pi-extension/server/ai-runtime.ts | 156 +++++++ apps/pi-extension/server/serverAnnotate.ts | 8 +- apps/pi-extension/server/serverPlan.ts | 9 +- apps/pi-extension/server/serverReview.ts | 151 +------ bun.lock | 8 +- packages/ai/ai.test.ts | 22 +- packages/ai/context.ts | 47 ++- packages/ai/types.ts | 10 + packages/editor/App.tsx | 304 +++++++++++++- packages/editor/components/AppHeader.tsx | 26 ++ packages/editor/package.json | 1 + packages/review-editor/App.tsx | 82 ++-- packages/review-editor/components/AITab.tsx | 2 +- .../components/AnnotationToolbar.tsx | 2 +- .../review-editor/components/AskAIInput.tsx | 2 +- .../components/InlineAIMarker.tsx | 2 +- packages/review-editor/hooks/useAIChat.ts | 327 +-------------- packages/server/ai-runtime.ts | 102 +++++ packages/server/annotate.ts | 19 +- packages/server/index.ts | 25 +- packages/server/review.ts | 99 +---- packages/shared/agents.ts | 29 +- packages/ui/aiProvider.test.ts | 78 ++++ packages/ui/components/AISettingsTab.tsx | 20 +- packages/ui/components/AISetupDialog.tsx | 153 ------- packages/ui/components/CommentPopover.tsx | 50 +++ .../components/PlanAIAnnouncementDialog.tsx | 147 +++++++ packages/ui/components/Settings.tsx | 10 +- .../components/SparklesIcon.tsx | 2 +- packages/ui/components/Viewer.tsx | 40 +- packages/ui/components/ai/AIProviderBar.tsx | 110 +++++ .../ui/components/ai/DocumentAIChatPanel.tsx | 275 ++++++++++++ .../ui/components/html-viewer/HtmlViewer.tsx | 12 +- .../html-viewer/useHtmlAnnotation.ts | 3 +- packages/ui/hooks/useAIChat.ts | 390 ++++++++++++++++++ packages/ui/hooks/useAnnotationHighlighter.ts | 3 + packages/ui/package.json | 2 + packages/ui/theme.css | 85 ++++ packages/ui/types.ts | 6 + packages/ui/utils/aiProvider.ts | 128 +++++- packages/ui/utils/aiSetup.ts | 18 - packages/ui/utils/planAIAnnouncement.ts | 17 + 46 files changed, 2233 insertions(+), 813 deletions(-) create mode 100644 apps/pi-extension/server/ai-runtime.ts create mode 100644 packages/server/ai-runtime.ts create mode 100644 packages/ui/aiProvider.test.ts delete mode 100644 packages/ui/components/AISetupDialog.tsx create mode 100644 packages/ui/components/PlanAIAnnouncementDialog.tsx rename packages/{review-editor => ui}/components/SparklesIcon.tsx (97%) create mode 100644 packages/ui/components/ai/AIProviderBar.tsx create mode 100644 packages/ui/components/ai/DocumentAIChatPanel.tsx create mode 100644 packages/ui/hooks/useAIChat.ts delete mode 100644 packages/ui/utils/aiSetup.ts create mode 100644 packages/ui/utils/planAIAnnouncement.ts diff --git a/AGENTS.md b/AGENTS.md index 59af5f634..fa30584a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | -| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`. Invalid values silently fall through to env-based detection. Unset by default. | +| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `pi`. Invalid values silently fall through to env-based detection. Unset by default. | | `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. | | `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. | | `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. | @@ -173,6 +173,21 @@ Send Feedback → feedback sent to agent session Approve → "LGTM" sent to agent session ``` +## Ask AI Provider Defaults + +Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`: + +| Origin | Preferred Ask AI provider | +|--------|---------------------------| +| `claude-code` | `claude-agent-sdk` | +| `codex` | `codex-sdk` | +| `opencode` | `opencode-sdk` | +| `pi` | `pi-sdk` | +| `copilot-cli` | no dedicated provider; fallback to saved/server default | +| `gemini-cli` | no dedicated provider; fallback to saved/server default | + +Per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another. + ## Annotate Flow ``` @@ -235,6 +250,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | | `/api/editor-annotations` | GET | List editor annotations (VS Code only) | | `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | +| `/api/ai/capabilities` | GET | Check if AI features are available | +| `/api/ai/session` | POST | Create or fork an AI session | +| `/api/ai/query` | POST | Send a message and stream the response (SSE) | +| `/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` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | @@ -293,6 +314,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=&base=`) | | `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) | | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | +| `/api/ai/capabilities` | GET | Check if AI features are available | +| `/api/ai/session` | POST | Create or fork an AI session | +| `/api/ai/query` | POST | Send a message and stream the response (SSE) | +| `/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` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | diff --git a/README.md b/README.md index befda40fe..74adabcbb 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your pla ### Features - + - +
Visual Plan ReviewBuilt-in hookApprove or deny agent plans with inline annotations
Visual Plan ReviewBuilt-in hookApprove or deny agent plans with inline annotations and Ask AI side chat
Plan DiffAutomaticSee what changed when the agent revises a plan
Code Review/plannotator-reviewView git diffs or remote PRs. Package annotations and ask AI about the code as you review.
Annotate Any File/plannotator-annotate <file|folder|url>Annotate markdown, HTML, URLs, or folders and send feedback to your agent
Annotate Any File/plannotator-annotate <file|folder|url>Annotate markdown, HTML, URLs, or folders, ask AI about the active document, and send feedback to your agent
Annotate Last Message/plannotator-lastAnnotate the agent's last response and send structured feedback
@@ -255,8 +255,9 @@ When your AI agent finishes planning, Plannotator: 1. Opens the Plannotator UI in your browser 2. Lets you annotate the plan visually (delete, insert, replace, comment) -3. **Approve** → Agent proceeds with implementation -4. **Request changes** → Your annotations are sent back as structured feedback +3. Lets you ask AI about the plan or a highlighted selection when a provider is available +4. **Approve** → Agent proceeds with implementation +5. **Request changes** → Your annotations are sent back as structured feedback (Similar flow for code review, except you can also comment on specific lines of code diffs) diff --git a/apps/marketing/src/content/docs/guides/ai-features.md b/apps/marketing/src/content/docs/guides/ai-features.md index cbd9ecf4c..aded351dd 100644 --- a/apps/marketing/src/content/docs/guides/ai-features.md +++ b/apps/marketing/src/content/docs/guides/ai-features.md @@ -1,12 +1,14 @@ --- title: AI Features -description: "How to use Plannotator's inline AI chat during code review — provider setup, model selection, and how it works." +description: "How to use Plannotator's AI chat during plan review, annotate, and code review — provider setup, model selection, and how it works." sidebar: order: 25 section: "Guides" --- -Plannotator embeds an AI chat sidebar directly in the code review UI. You can select lines in a diff, ask questions, and get streaming responses. The AI sees the full diff context automatically, so you can ask things like "explain this change" or "is this safe?" without copy-pasting code. +Plannotator embeds an AI chat sidebar directly in live review sessions. In plan review and annotate, you can ask a general question about the current plan or document, or select text, open the comment popover, and choose **Ask AI**. In code review, you can select lines in a diff and ask questions about the code. + +The AI sees the relevant review context automatically: the current plan and previous plan version for plan review, the active document and source metadata for annotate, or the full diff for code review. AI chat history stays separate from approve, deny, and send-annotations output unless you manually copy text into normal feedback. ## Supported providers @@ -49,6 +51,8 @@ OpenCode supports session forking, resuming, and runtime permission approvals Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions. +By default, Plannotator prefers the provider that matches the detected agent origin: Claude Code uses Claude, Codex uses Codex, OpenCode uses OpenCode, and Pi uses Pi when those providers are available. GitHub Copilot CLI and Gemini CLI do not have dedicated Ask AI providers yet, so they fall back to your saved provider or the server default. + You can also override the provider and model per-session using the config bar at the bottom of the AI sidebar. Changing the provider or model starts a new session — old messages stay visible but the conversation resets. ## How it works @@ -63,7 +67,7 @@ A session is created lazily on your first question. Until then, no resources are **OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline. -**Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation. +**Context handling:** Large plans, documents, and diffs are truncated to stay within context limits. When you ask from a selection, the selected text or selected code is always sent alongside the question regardless of truncation. In folder annotation mode, Ask AI is scoped to the currently opened document only. ## Permission requests diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index bbfafaff9..3981ee565 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -511,6 +511,7 @@ describe("pi review server", () => { const vcsContext = await getVcsContext(repoDir); expect(vcsContext.vcsType).toBe("jj"); + const expectedJjBase = vcsContext.defaultBranch; const prepared = await prepareLocalReviewDiff({ cwd: repoDir, requestedDiffType: "merge-base", @@ -519,7 +520,7 @@ describe("pi review server", () => { }); expect(prepared.gitContext.vcsType).toBe("jj"); expect(prepared.diffType).toBe("jj-current"); - expect(prepared.base).toBe("main@git"); + expect(prepared.base).toBe(expectedJjBase); const forcedGit = await prepareLocalReviewDiff({ cwd: repoDir, @@ -578,14 +579,13 @@ describe("pi review server", () => { gitContext?: { vcsType?: string; diffOptions: Array<{ id: string }> }; }; expect(initial.diffType).toBe("jj-current"); - expect(initial.base).toBe("main@git"); + expect(initial.base).toBe(expectedJjBase); expect(initial.gitContext?.vcsType).toBe("jj"); - expect(initial.gitContext?.diffOptions.map((option) => option.id)).toEqual([ - "jj-current", - "jj-last", - "jj-line", - "jj-all", - ]); + const optionIds = initial.gitContext?.diffOptions.map((option) => option.id) ?? []; + expect(optionIds).toContain("jj-current"); + expect(optionIds).toContain("jj-last"); + expect(optionIds).toContain("jj-line"); + expect(optionIds).toContain("jj-all"); expect(initial.rawPatch).toContain("tracked.txt"); expect(initial.rawPatch).toContain("+after"); diff --git a/apps/pi-extension/server/ai-runtime.ts b/apps/pi-extension/server/ai-runtime.ts new file mode 100644 index 000000000..96eb6687f --- /dev/null +++ b/apps/pi-extension/server/ai-runtime.ts @@ -0,0 +1,156 @@ +import { execSync } from "node:child_process"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; + +import { json, toWebRequest } from "./helpers.js"; + +export interface PiAIRuntime { + endpoints: Record Promise>; + dispose: () => void; +} + +interface CreatePiAIRuntimeOptions { + cwd?: string; + getCwd?: () => string; +} + +function whichCmd(cmd: string): string | null { + try { + return ( + execSync(`which ${cmd}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim() || null + ); + } catch { + return null; + } +} + +export async function createPiAIRuntime(options: CreatePiAIRuntimeOptions = {}): Promise { + try { + const ai = await import("../generated/ai/index.js"); + const cwd = options.cwd ?? process.cwd(); + const registry = new ai.ProviderRegistry(); + const sessionManager = new ai.SessionManager(); + + try { + await import("../generated/ai/providers/claude-agent-sdk.js"); + const claudePath = whichCmd("claude"); + const provider = await ai.createProvider({ + type: "claude-agent-sdk", + cwd, + ...(claudePath && { claudeExecutablePath: claudePath }), + }); + registry.register(provider); + } catch { + // Claude SDK not available. + } + + try { + await import("../generated/ai/providers/codex-sdk.js"); + await import("@openai/codex-sdk"); + const codexPath = whichCmd("codex"); + const provider = await ai.createProvider({ + type: "codex-sdk", + cwd, + ...(codexPath && { codexExecutablePath: codexPath }), + }); + registry.register(provider); + } catch { + // Codex SDK not available. + } + + try { + await import("../generated/ai/providers/pi-sdk-node.js"); + const piPath = whichCmd("pi"); + if (piPath) { + const provider = await ai.createProvider({ + type: "pi-sdk", + cwd, + piExecutablePath: piPath, + } as any); + if (provider && "fetchModels" in provider) { + await (provider as { fetchModels: () => Promise }).fetchModels(); + } + registry.register(provider); + } + } catch { + // Pi not available. + } + + try { + await import("../generated/ai/providers/opencode-sdk.js"); + const opencodePath = whichCmd("opencode"); + if (opencodePath) { + const provider = await ai.createProvider({ + type: "opencode-sdk", + cwd, + }); + if (provider && "fetchModels" in provider) { + await (provider as { fetchModels: () => Promise }).fetchModels(); + } + registry.register(provider); + } + } catch { + // OpenCode not available. + } + + return { + endpoints: ai.createAIEndpoints({ + registry, + sessionManager, + getCwd: options.getCwd, + }), + dispose: () => { + sessionManager.disposeAll(); + registry.disposeAll(); + }, + }; + } catch { + return null; + } +} + +export async function handlePiAIRequest( + req: IncomingMessage, + res: ServerResponse, + url: URL, + runtime: PiAIRuntime | null, +): Promise { + if (!url.pathname.startsWith("/api/ai/")) return false; + + if (!runtime) { + if (url.pathname === "/api/ai/capabilities" && req.method === "GET") { + json(res, { available: false, providers: [] }); + return true; + } + json(res, { error: "AI backend not available" }, 503); + return true; + } + + const handler = runtime.endpoints[url.pathname]; + if (!handler) { + json(res, { error: "Not found" }, 404); + return true; + } + + try { + const webReq = toWebRequest(req); + const webRes = await handler(webReq); + const headers: Record = {}; + webRes.headers.forEach((value, key) => { + headers[key] = value; + }); + res.writeHead(webRes.status, headers); + if (webRes.body) { + Readable.fromWeb(webRes.body as any).pipe(res); + } else { + res.end(); + } + } catch (err) { + json(res, { error: err instanceof Error ? err.message : "AI endpoint error" }, 500); + } + + return true; +} diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index d9ab9747f..e3606c29b 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -11,6 +11,7 @@ import { handleUploadRequest, } from "./handlers.js"; import { html, json, parseBody, requestUrl } from "./helpers.js"; +import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js"; import { listenOnPort } from "./network.js"; @@ -86,11 +87,13 @@ export async function startAnnotateServer(options: { const repoInfo = getRepoInfo(); const externalAnnotations = createExternalAnnotationHandler("plan"); + const aiRuntime = await createPiAIRuntime(); const server = createServer(async (req, res) => { const url = requestUrl(req); if (await externalAnnotations.handle(req, res, url)) return; + if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) return; if (url.pathname === "/api/plan" && req.method === "GET") { json(res, { @@ -180,6 +183,9 @@ export async function startAnnotateServer(options: { portSource, url: `http://localhost:${port}`, waitForDecision: () => decisionPromise, - stop: () => server.close(), + stop: () => { + aiRuntime?.dispose(); + server.close(); + }, }; } diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 06ba52754..cc95c094b 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -24,6 +24,7 @@ import { handleUploadRequest, } from "./handlers.js"; import { html, json, parseBody, requestUrl } from "./helpers.js"; +import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js"; import { openEditorDiff } from "./ide.js"; import { type BearConfig, @@ -156,6 +157,7 @@ export async function startPlanReviewServer(options: { // Editor annotations (in-memory, VS Code integration — skip in archive mode) const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null; const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null; + const aiRuntime = options.mode !== "archive" ? await createPiAIRuntime() : null; // Lazy cache for in-session archive tab let cachedArchivePlans: ArchivedPlan[] | null = null; @@ -267,6 +269,8 @@ export async function startPlanReviewServer(options: { return; } else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) { return; + } else if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) { + return; } else if (url.pathname === "/api/doc" && req.method === "GET") { await handleDocRequest(res, url); } else if (url.pathname === "/api/doc/exists" && req.method === "POST") { @@ -493,6 +497,9 @@ export async function startPlanReviewServer(options: { }; }, ...(donePromise && { waitForDone: () => donePromise }), - stop: () => server.close(), + stop: () => { + aiRuntime?.dispose(); + server.close(); + }, }; } diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 98fa2deaa..f3436f016 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -1,10 +1,8 @@ -import { execSync, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; -import { Readable } from "node:stream"; - import { contentHash, deleteDraft } from "../generated/draft.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; @@ -53,7 +51,8 @@ import { handleImageRequest, handleUploadRequest, } from "./handlers.js"; -import { html, json, parseBody, requestUrl, toWebRequest } from "./helpers.js"; +import { html, json, parseBody, requestUrl } from "./helpers.js"; +import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js"; import { isRemoteSession, listenOnPort } from "./network.js"; import { @@ -441,116 +440,7 @@ export async function startReviewServer(options: { resolveDecision = r; }); - // AI provider setup (graceful — AI features degrade if SDK unavailable) - // Types are `any` because @plannotator/ai is a dynamic import - let aiEndpoints: Record Promise> | null = - null; - let aiSessionManager: { disposeAll: () => void } | null = null; - let aiRegistry: { disposeAll: () => void } | null = null; - try { - const ai = await import("../generated/ai/index.js"); - const registry = new ai.ProviderRegistry(); - const sessionManager = new ai.SessionManager(); - - // which() helper for Node.js - const whichCmd = (cmd: string): string | null => { - try { - return ( - execSync(`which ${cmd}`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim() || null - ); - } catch { - return null; - } - }; - - // Claude Agent SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/claude-agent-sdk.js"); - const claudePath = whichCmd("claude"); - const provider = await ai.createProvider({ - type: "claude-agent-sdk", - cwd: process.cwd(), - ...(claudePath && { claudeExecutablePath: claudePath }), - }); - registry.register(provider); - } catch { - /* Claude SDK not available */ - } - - // Codex SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/codex-sdk.js"); - await import("@openai/codex-sdk"); - const codexPath = whichCmd("codex"); - const provider = await ai.createProvider({ - type: "codex-sdk", - cwd: process.cwd(), - ...(codexPath && { codexExecutablePath: codexPath }), - }); - registry.register(provider); - } catch { - /* Codex SDK not available */ - } - - // Pi SDK (Node.js variant) - try { - await import("../generated/ai/providers/pi-sdk-node.js"); - const piPath = whichCmd("pi"); - if (piPath) { - const provider = await ai.createProvider({ - type: "pi-sdk", - cwd: process.cwd(), - piExecutablePath: piPath, - } as any); - if (provider && "fetchModels" in provider) { - await ( - provider as { fetchModels: () => Promise } - ).fetchModels(); - } - registry.register(provider); - } - } catch { - /* Pi not available */ - } - - // OpenCode SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/opencode-sdk.js"); - const opencodePath = whichCmd("opencode"); - if (opencodePath) { - const provider = await ai.createProvider({ - type: "opencode-sdk", - cwd: process.cwd(), - }); - if (provider && "fetchModels" in provider) { - await ( - provider as { fetchModels: () => Promise } - ).fetchModels(); - } - registry.register(provider); - } - } catch { - /* OpenCode not available */ - } - - if (registry.size > 0) { - aiEndpoints = ai.createAIEndpoints({ - registry, - sessionManager, - getCwd: resolveAgentCwd, - }); - aiSessionManager = sessionManager; - aiRegistry = registry; - } - } catch { - /* AI backbone not available */ - } + const aiRuntime = await createPiAIRuntime({ getCwd: resolveAgentCwd }); const server = createServer(async (req, res) => { const url = requestUrl(req); @@ -1097,34 +987,8 @@ export async function startReviewServer(options: { return; } else if (await agentJobs.handle(req, res, url)) { return; - } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { - const handler = aiEndpoints[url.pathname]; - if (handler) { - try { - const webReq = toWebRequest(req); - const webRes = await handler(webReq); - // Pipe Web Response → node:http response - const headers: Record = {}; - webRes.headers.forEach((v, k) => { - headers[k] = v; - }); - res.writeHead(webRes.status, headers); - if (webRes.body) { - const nodeStream = Readable.fromWeb(webRes.body as any); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (err) { - json( - res, - { error: err instanceof Error ? err.message : "AI endpoint error" }, - 500, - ); - } - return; - } - json(res, { error: "Not found" }, 404); + } else if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) { + return; } else if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); resolveDecision({ approved: false, feedback: '', annotations: [], exit: true }); @@ -1167,8 +1031,7 @@ export async function startReviewServer(options: { stop: () => { process.removeListener("exit", exitHandler); agentJobs.killAll(); - aiSessionManager?.disposeAll(); - aiRegistry?.disposeAll(); + aiRuntime?.dispose(); server.close(); // Invoke cleanup callback (e.g., remove temp worktree) if (options.onCleanup) { diff --git a/bun.lock b/bun.lock index bc637b46a..76144701a 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.19.17", + "version": "0.19.20", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.19.17", + "version": "0.19.20", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", @@ -161,6 +161,7 @@ "name": "@plannotator/editor", "version": "0.0.1", "dependencies": { + "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "react": "^19.2.3", @@ -192,7 +193,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.17", + "version": "0.19.20", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -215,6 +216,7 @@ "version": "0.0.1", "dependencies": { "@pierre/diffs": "^1.1.12", + "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ai/ai.test.ts b/packages/ai/ai.test.ts index 98eea78d2..8c6069dab 100644 --- a/packages/ai/ai.test.ts +++ b/packages/ai/ai.test.ts @@ -205,12 +205,21 @@ describe("Context builders", () => { test("buildSystemPrompt for plan-review", () => { const ctx: AIContext = { mode: "plan-review", - plan: { plan: "# My Plan\n\nStep 1: do things" }, + plan: { + plan: "# My Plan\n\nStep 1: do things", + previousPlan: "# Old Plan", + version: 3, + totalVersions: 4, + project: "plannotator", + }, }; const prompt = buildSystemPrompt(ctx); expect(prompt).toContain("Plannotator"); expect(prompt).toContain("# My Plan"); expect(prompt).toContain("Step 1: do things"); + expect(prompt).toContain("Plan version: 3 of 4"); + expect(prompt).toContain("Project: plannotator"); + expect(prompt).toContain("# Old Plan"); }); test("buildSystemPrompt for code-review", () => { @@ -226,11 +235,20 @@ describe("Context builders", () => { test("buildSystemPrompt for annotate", () => { const ctx: AIContext = { mode: "annotate", - annotate: { content: "# Doc\nSome content", filePath: "/tmp/test.md" }, + annotate: { + content: "# Doc\nSome content", + filePath: "/tmp/test.md", + sourceInfo: "https://example.com/doc.html", + sourceConverted: true, + renderAs: "html", + }, }; const prompt = buildSystemPrompt(ctx); expect(prompt).toContain("Plannotator"); expect(prompt).toContain("/tmp/test.md"); + expect(prompt).toContain("https://example.com/doc.html"); + expect(prompt).toContain("Render mode: html"); + expect(prompt).toContain("converted before annotation"); }); test("buildForkPreamble includes context and instructions", () => { diff --git a/packages/ai/context.ts b/packages/ai/context.ts index 173cbae1b..40e0fdce9 100644 --- a/packages/ai/context.ts +++ b/packages/ai/context.ts @@ -49,6 +49,13 @@ export function buildForkPreamble(ctx: AIContext): string { switch (ctx.mode) { case "plan-review": { lines.push("## Current Plan Under Review"); + if (ctx.plan.version) { + const total = ctx.plan.totalVersions ? ` of ${ctx.plan.totalVersions}` : ""; + lines.push(`Plan version: ${ctx.plan.version}${total}`); + } + if (ctx.plan.project) { + lines.push(`Project: ${ctx.plan.project}`); + } lines.push(""); lines.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS)); if (ctx.plan.annotations) { @@ -87,6 +94,15 @@ export function buildForkPreamble(ctx: AIContext): string { } case "annotate": { lines.push(`## Annotating: ${ctx.annotate.filePath}`); + if (ctx.annotate.sourceInfo) { + lines.push(`Source: ${ctx.annotate.sourceInfo}`); + } + if (ctx.annotate.renderAs) { + lines.push(`Render mode: ${ctx.annotate.renderAs}`); + } + if (ctx.annotate.sourceConverted) { + lines.push("Note: this content was converted before annotation, so source line numbers may not match the original document."); + } lines.push(""); lines.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS)); if (ctx.annotate.annotations) { @@ -136,10 +152,20 @@ function buildPlanReviewPrompt( "The user is reviewing an implementation plan in Plannotator.", "", "## Plan Under Review", - "", - truncate(ctx.plan.plan, MAX_PLAN_CHARS), ]; + if (ctx.plan.version) { + const total = ctx.plan.totalVersions ? ` of ${ctx.plan.totalVersions}` : ""; + sections.push(`Plan version: ${ctx.plan.version}${total}`); + } + + if (ctx.plan.project) { + sections.push(`Project: ${ctx.plan.project}`); + } + + sections.push(""); + sections.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS)); + if (ctx.plan.previousPlan) { sections.push(""); sections.push("## Previous Plan Version (for reference)"); @@ -197,10 +223,23 @@ function buildAnnotatePrompt( "The user is annotating a markdown document in Plannotator.", "", `## Document: ${ctx.annotate.filePath}`, - "", - truncate(ctx.annotate.content, MAX_PLAN_CHARS), ]; + if (ctx.annotate.sourceInfo) { + sections.push(`Source: ${ctx.annotate.sourceInfo}`); + } + + if (ctx.annotate.renderAs) { + sections.push(`Render mode: ${ctx.annotate.renderAs}`); + } + + if (ctx.annotate.sourceConverted) { + sections.push("Note: this content was converted before annotation, so source line numbers may not match the original document."); + } + + sections.push(""); + sections.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS)); + if (ctx.annotate.annotations) { sections.push(""); sections.push("## User Annotations"); diff --git a/packages/ai/types.ts b/packages/ai/types.ts index 9353c2e39..b87e79f8f 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -35,6 +35,10 @@ export interface PlanContext { previousPlan?: string; /** The version number in the plan's history. */ version?: number; + /** Total number of versions in the plan's history. */ + totalVersions?: number; + /** Project/repository label used for plan history. */ + project?: string; /** Annotations the user has made so far (serialised for the prompt). */ annotations?: string; } @@ -65,6 +69,12 @@ export interface AnnotateContext { content: string; /** Path to the file on disk. */ filePath: string; + /** Source attribution shown in the UI, such as an original URL or filename. */ + sourceInfo?: string; + /** True when the document was converted from HTML or a remote reader result. */ + sourceConverted?: boolean; + /** Render mode for the annotated content. */ + renderAs?: "markdown" | "html"; /** Summary of annotations the user has made. */ annotations?: string; } diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c8e12d2e1..1da77465a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -5,6 +5,8 @@ import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, e import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { HtmlViewer } from '@plannotator/ui/components/html-viewer'; import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; +import { DocumentAIChatPanel } from '@plannotator/ui/components/ai/DocumentAIChatPanel'; +import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon'; import { ExportModal } from '@plannotator/ui/components/ExportModal'; import { ImportModal } from '@plannotator/ui/components/ImportModal'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; @@ -23,12 +25,21 @@ import { storage } from '@plannotator/ui/utils/storage'; import { configStore } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; +import { PlanAIAnnouncementDialog } from '@plannotator/ui/components/PlanAIAnnouncementDialog'; import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; import { getBearSettings } from '@plannotator/ui/utils/bear'; import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine'; import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; +import { + getAIProviderSettings, + resolveAIModelForProvider, + resolveAIProviderSelection, + saveAIProviderSelection, +} from '@plannotator/ui/utils/aiProvider'; +import { markPlanAIAnnouncementSeen, needsPlanAIAnnouncement } from '@plannotator/ui/utils/planAIAnnouncement'; +import { useAIChat } from '@plannotator/ui/hooks/useAIChat'; import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode'; import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod'; @@ -74,6 +85,8 @@ import { type GoalSetupSurfaceHandle, } from '@plannotator/ui/components/goal-setup/GoalSetupSurface'; import type { GoalSetupBundle } from '@plannotator/shared/goal-setup'; +import type { AIContext } from '@plannotator/ai'; +import type { CommentAskAIContext } from '@plannotator/ui/components/CommentPopover'; // Demo content toggle. Default: the original Real-time Collaboration plan. // Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps // in the 20-case Auth Service Refactor test plan. dev-mock-api.ts reads the @@ -114,6 +127,7 @@ const App: React.FC = () => { const [showAgentWarning, setShowAgentWarning] = useState(false); const [agentWarningMessage, setAgentWarningMessage] = useState(''); const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); + const [rightSidebarTab, setRightSidebarTab] = useState<'annotations' | 'ai'>('annotations'); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false); const [editorMode, setEditorMode] = useState(getEditorMode); const [inputMethod, setInputMethod] = useState(getInputMethod); @@ -183,6 +197,19 @@ const App: React.FC = () => { const [planDiffMode, setPlanDiffMode] = useState('clean'); const [previousPlan, setPreviousPlan] = useState(null); const [versionInfo, setVersionInfo] = useState(null); + const [aiSessionEnabled, setAISessionEnabled] = useState(false); + 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 providerId = saved.providerId; + return { + providerId, + model: providerId ? (saved.preferredModels[providerId] ?? null) : null, + reasoningEffort: null as string | null, + }; + }); + const [showPlanAIAnnouncement, setShowPlanAIAnnouncement] = useState(needsPlanAIAnnouncement); const isMobile = useIsMobile(); const viewerRef = useRef(null); @@ -265,10 +292,28 @@ const App: React.FC = () => { const handleAnnotationPanelToggle = useCallback(() => { if (wideModeType !== null) { exitWideMode({ restore: false, panelOpen: true }); + setRightSidebarTab('annotations'); + return; + } + setRightSidebarTab('annotations'); + setIsPanelOpen(prev => rightSidebarTab === 'annotations' ? !prev : true); + }, [exitWideMode, rightSidebarTab, wideModeType]); + + const dismissPlanAIAnnouncement = useCallback(() => { + markPlanAIAnnouncementSeen(); + setShowPlanAIAnnouncement(false); + }, []); + + const handleAIChatToggle = useCallback(() => { + dismissPlanAIAnnouncement(); + if (wideModeType !== null) { + exitWideMode({ restore: false, panelOpen: true }); + setRightSidebarTab('ai'); return; } - setIsPanelOpen(prev => !prev); - }, [exitWideMode, wideModeType]); + setRightSidebarTab('ai'); + setIsPanelOpen(prev => rightSidebarTab === 'ai' ? !prev : true); + }, [dismissPlanAIAnnouncement, exitWideMode, rightSidebarTab, wideModeType]); // Sync sidebar open state when preference changes in Settings useEffect(() => { @@ -735,6 +780,7 @@ const App: React.FC = () => { .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); + setAISessionEnabled(data.mode !== 'archive' && data.mode !== 'goal-setup'); // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable setGitUser(data.serverConfig?.gitUser); if (data.mode === 'goal-setup' && data.goalSetup) { @@ -815,10 +861,62 @@ const App: React.FC = () => { .catch(() => { // Not in API mode - use default content setIsApiMode(false); + setAISessionEnabled(false); }) .finally(() => setIsLoading(false)); }, [isLoadingShared, isSharedSession]); + useEffect(() => { + if (!aiSessionEnabled || !isApiMode || isSharedSession) { + setAiAvailable(false); + setAiProviders([]); + return; + } + + let cancelled = false; + fetch('/api/ai/capabilities') + .then(res => res.ok ? res.json() : null) + .then(data => { + if (cancelled) return; + if (data?.available) { + const providers = data.providers ?? []; + setAiAvailable(true); + setAiProviders(providers); + setAIConfig(prev => { + const saved = getAIProviderSettings(); + const selection = resolveAIProviderSelection({ + providers, + origin, + settings: saved, + serverDefaultProvider: data.defaultProvider ?? null, + }); + + if (prev.providerId === selection.providerId && prev.model === selection.model) return prev; + + saveAIProviderSelection({ + providerId: selection.providerId, + model: selection.model, + origin, + settings: saved, + }); + + return { ...prev, providerId: selection.providerId, model: selection.model }; + }); + } else { + setAiAvailable(false); + setAiProviders([]); + } + }) + .catch(() => { + if (!cancelled) { + setAiAvailable(false); + setAiProviders([]); + } + }); + + return () => { cancelled = true; }; + }, [aiSessionEnabled, isApiMode, isSharedSession, origin]); + // Auto-save to notes apps on plan arrival (each gated by its autoSave toggle) const autoSaveAttempted = useRef(false); const autoSaveResultsRef = useRef({}); @@ -1387,6 +1485,135 @@ const App: React.FC = () => { return output; }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]); + const aiAnnotationsContext = useMemo( + () => annotationsOutput === 'User reviewed the document and has no feedback.' ? undefined : annotationsOutput, + [annotationsOutput], + ); + + const aiDocumentPath = linkedDocHook.isActive + ? linkedDocHook.filepath ?? 'linked document' + : sourceFilePath ?? (annotateSource === 'message' ? 'agent message' : annotateSource === 'folder' ? 'folder document' : 'plan'); + const aiSourceInfo = linkedDocHook.isActive ? linkedDocHook.filepath ?? undefined : sourceInfo; + const aiSourceConverted = linkedDocHook.isActive + ? (linkedDocHook.getDocAnnotations().get(linkedDocHook.filepath ?? '')?.isConverted ?? false) + : sourceConverted; + const aiRenderAs = linkedDocHook.isActive ? 'markdown' : renderAs; + const aiDocumentMode = annotateMode || linkedDocHook.isActive; + + const aiContext = useMemo(() => { + if (!aiSessionEnabled || archive.archiveMode || goalSetupMode) return null; + + if (aiDocumentMode) { + return { + mode: 'annotate', + annotate: { + content: aiRenderAs === 'html' && rawHtml ? rawHtml : markdown, + filePath: aiDocumentPath, + sourceInfo: aiSourceInfo, + sourceConverted: aiSourceConverted, + renderAs: aiRenderAs, + annotations: aiAnnotationsContext, + }, + }; + } + + return { + mode: 'plan-review', + plan: { + plan: markdown, + previousPlan: previousPlan ?? undefined, + version: versionInfo?.version, + totalVersions: versionInfo?.totalVersions, + project: versionInfo?.project, + annotations: aiAnnotationsContext, + }, + }; + }, [ + aiAnnotationsContext, + aiDocumentPath, + aiRenderAs, + aiSessionEnabled, + aiSourceConverted, + aiSourceInfo, + aiDocumentMode, + archive.archiveMode, + goalSetupMode, + markdown, + previousPlan, + rawHtml, + renderAs, + versionInfo, + ]); + + const aiChat = useAIChat({ + context: aiContext, + providerId: aiConfig.providerId, + model: aiConfig.model, + reasoningEffort: aiConfig.reasoningEffort, + threadTitle: aiDocumentMode ? 'Document chat' : 'Plan chat', + }); + + const aiDocumentKey = `${aiDocumentMode ? 'document' : 'plan'}:${aiRenderAs}:${aiDocumentPath}:${versionInfo?.version ?? 'current'}`; + const previousAIDocumentKeyRef = useRef(null); + useEffect(() => { + if (!aiSessionEnabled) return; + if (previousAIDocumentKeyRef.current && previousAIDocumentKeyRef.current !== aiDocumentKey) { + aiChat.resetThread(); + } + previousAIDocumentKeyRef.current = aiDocumentKey; + }, [aiChat, aiDocumentKey, aiSessionEnabled]); + + const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => { + setAIConfig(prev => { + const saved = getAIProviderSettings(); + const providerId = config.providerId !== undefined ? config.providerId : prev.providerId; + const providerChanged = config.providerId !== undefined && config.providerId !== prev.providerId; + const provider = aiProviders.find(p => p.id === providerId) ?? null; + const model = providerChanged + ? (config.model !== undefined ? config.model : resolveAIModelForProvider(provider, saved.preferredModels)) + : (config.model !== undefined ? config.model : prev.model); + const next = { ...prev, ...config, providerId, model }; + saveAIProviderSelection({ + providerId: next.providerId, + model: next.model, + origin, + settings: saved, + }); + return next; + }); + aiChat.resetSession(); + }, [aiChat, aiProviders, origin]); + + const openAIChat = useCallback(() => { + setRightSidebarTab('ai'); + setIsPanelOpen(true); + }, []); + + const handleOpenAIAnnouncement = useCallback(() => { + dismissPlanAIAnnouncement(); + openAIChat(); + }, [dismissPlanAIAnnouncement, openAIChat]); + + const handleAskAI = useCallback((question: string, context?: CommentAskAIContext) => { + if (!aiAvailable) return; + dismissPlanAIAnnouncement(); + openAIChat(); + aiChat.ask({ + prompt: question, + scope: context ? { + kind: context.kind, + label: context.label, + text: context.text, + sourcePath: context.sourcePath ?? aiDocumentPath, + } : undefined, + contextUpdate: aiChat.sessionId ? aiAnnotationsContext : undefined, + }); + }, [aiAnnotationsContext, aiAvailable, aiChat, aiDocumentPath, dismissPlanAIAnnouncement, openAIChat]); + + const handleAskGeneralAI = useCallback((question: string) => { + handleAskAI(question, { kind: 'general', label: aiDocumentMode ? 'Document' : 'Plan', sourcePath: aiDocumentPath }); + }, [aiDocumentMode, aiDocumentPath, handleAskAI]); + // Bot callback config — read once from URL search params (?cb=&ct=) // TODO: bot callbacks post shareUrl which doesn't include code-file annotations. // If a user adds code comments and hits the callback button, those comments are silently dropped. @@ -1668,6 +1895,17 @@ const App: React.FC = () => { return widths[uiPrefs.planWidth] ?? 832; }, [uiPrefs.planWidth]); const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth; + const selectedAIProvider = aiProviders.find(provider => provider.id === aiConfig.providerId) ?? null; + const shouldShowPlanAIAnnouncement = + showPlanAIAnnouncement && + aiAvailable && + aiSessionEnabled && + isApiMode && + !isSharedSession && + !archive.archiveMode && + !goalSetupMode && + !showPermissionModeSetup && + !submitted; if (isLoading && !isSharedSession) { @@ -1695,7 +1933,10 @@ const App: React.FC = () => { origin={origin} isSubmitting={isSubmitting} isExiting={isExiting} - isPanelOpen={isPanelOpen} + isPanelOpen={isPanelOpen && rightSidebarTab === 'annotations'} + aiAvailable={aiAvailable} + isAIChatOpen={isPanelOpen && rightSidebarTab === 'ai'} + aiHasMessages={aiChat.messages.length > 0} hasAnyAnnotations={hasAnyAnnotations} linkedDocIsActive={linkedDocHook.isActive} callbackShareUrlReady={callbackConfig ? Boolean(shareUrl || shortShareUrl) : true} @@ -1717,6 +1958,7 @@ const App: React.FC = () => { onFeedback={handleHeaderFeedback} onApprove={handleHeaderApprove} onAnnotationPanelToggle={handleAnnotationPanelToggle} + onAIChatToggle={handleAIChatToggle} onArchiveCopy={archive.copy} onArchiveDone={archive.done} onTaterModeChange={handleTaterModeChange} @@ -1964,6 +2206,7 @@ const App: React.FC = () => { onAddGlobalAttachment={handleAddGlobalAttachment} onRemoveGlobalAttachment={handleRemoveGlobalAttachment} maxWidth={annotateReaderMaxWidth} + onAskAI={aiAvailable ? handleAskAI : undefined} /> ) : ( { onToggleCheckbox={checkbox.toggle} checkboxOverrides={checkbox.overrides} actionsLabelMode={actionsLabelMode} + onAskAI={aiAvailable ? handleAskAI : undefined} /> )} @@ -2012,7 +2256,7 @@ const App: React.FC = () => { {/* Annotation Panel */} { otherFileAnnotations={otherFileAnnotations} onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} /> + {isPanelOpen && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && ( + + )} @@ -2217,6 +2505,14 @@ const App: React.FC = () => { {/* Update notification */} + + {/* Image Annotator for pasted images */} void; onApprove: () => void; onAnnotationPanelToggle: () => void; + onAIChatToggle: () => void; onArchiveCopy: () => void; onArchiveDone: () => void; onTaterModeChange: (enabled: boolean) => void; @@ -91,6 +96,9 @@ export const AppHeader = React.memo(({ isSubmitting, isExiting, isPanelOpen, + aiAvailable, + isAIChatOpen, + aiHasMessages, hasAnyAnnotations, linkedDocIsActive, callbackShareUrlReady, @@ -112,6 +120,7 @@ export const AppHeader = React.memo(({ onFeedback, onApprove, onAnnotationPanelToggle, + onAIChatToggle, onArchiveCopy, onArchiveDone, onTaterModeChange, @@ -278,6 +287,23 @@ export const AppHeader = React.memo(({ )} + {!goalSetupMode && aiAvailable && ( + + )} {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
diff --git a/packages/editor/package.json b/packages/editor/package.json index 3e661eef8..f5d961b67 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -8,6 +8,7 @@ "./shortcuts": "./shortcuts.ts" }, "dependencies": { + "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "react": "^19.2.3", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 6c09036e1..dd082fa73 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -17,9 +17,12 @@ import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from ' 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 { + getAIProviderSettings, + resolveAIModelForProvider, + resolveAIProviderSelection, + saveAIProviderSelection, +} from '@plannotator/ui/utils/aiProvider'; 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'; @@ -41,7 +44,7 @@ import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockvi import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; import { ReviewSidebar } from './components/ReviewSidebar'; import type { ReviewSidebarTab } from './components/ReviewSidebar'; -import { SparklesIcon } from './components/SparklesIcon'; +import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon'; import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; import { FileTree } from './components/FileTree'; @@ -405,6 +408,7 @@ const ReviewApp: React.FC = () => { // AI Chat const [aiAvailable, setAiAvailable] = useState(false); const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); + const [aiDefaultProvider, setAiDefaultProvider] = useState(null); const [aiConfig, setAiConfig] = useState(() => { const saved = getAIProviderSettings(); const pid = saved.providerId; @@ -414,8 +418,6 @@ const ReviewApp: React.FC = () => { 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({ @@ -464,26 +466,56 @@ const ReviewApp: React.FC = () => { setAiAvailable(true); const providers = data.providers ?? []; setAiProviders(providers); + setAiDefaultProvider(data.defaultProvider ?? null); } - setAiCheckComplete(true); }) - .catch(() => { setAiCheckComplete(true); }); + .catch(() => {}); }, []); - const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null }) => { + useEffect(() => { + if (!aiAvailable || aiProviders.length === 0) return; + setAiConfig(prev => { + const saved = getAIProviderSettings(); + const selection = resolveAIProviderSelection({ + providers: aiProviders, + origin, + settings: saved, + serverDefaultProvider: aiDefaultProvider, + }); + + if (prev.providerId === selection.providerId && prev.model === selection.model) return prev; + + saveAIProviderSelection({ + providerId: selection.providerId, + model: selection.model, + origin, + settings: saved, + }); + + return { ...prev, providerId: selection.providerId, model: selection.model }; + }); + }, [aiAvailable, aiProviders, aiDefaultProvider, origin]); + + const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null; reasoningEffort?: 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 }); + const providerId = config.providerId !== undefined ? config.providerId : prev.providerId; + const providerChanged = config.providerId !== undefined && config.providerId !== prev.providerId; + const provider = aiProviders.find(p => p.id === providerId) ?? null; + const model = providerChanged + ? (config.model !== undefined ? config.model : resolveAIModelForProvider(provider, saved.preferredModels)) + : (config.model !== undefined ? config.model : prev.model); + const next = { ...prev, ...config, providerId, model }; + saveAIProviderSelection({ + providerId: next.providerId, + model: next.model, + origin, + settings: saved, + }); return next; }); aiChat.resetSession(); - }, [aiChat]); + }, [aiChat, aiProviders, origin]); const handleAskAI = useCallback((question: string) => { if (!pendingSelection || !files[activeFileIndex]) return; @@ -834,13 +866,13 @@ const ReviewApp: React.FC = () => { .finally(() => setIsLoading(false)); }, []); - // Show diff type setup dialog only after AI setup dialog is dismissed (avoid stacking) + // Show diff type setup after the initial diff payload marks it pending. useEffect(() => { - if (diffTypeSetupPending && aiCheckComplete && !showAISetup) { + if (diffTypeSetupPending) { setDiffTypeSetupPending(false); setShowDiffTypeSetup(true); } - }, [diffTypeSetupPending, aiCheckComplete, showAISetup]); + }, [diffTypeSetupPending]); const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => { configStore.set('diffStyle', style); @@ -2400,16 +2432,6 @@ const ReviewApp: React.FC = () => { showCancel /> - {/* AI setup dialog — first-run only */} - { - setShowAISetup(false); - handleAIConfigChange({ providerId }); - }} - /> - {/* Diff type setup dialog — first-run only */} {showDiffTypeSetup && ( ; - title?: string; - displayName?: string; - description?: string; - toolUseId: string; - decided?: 'allow' | 'deny'; -} +import { useAIChat as useSharedAIChat, type AIChatEntry, type PendingPermission } from '@plannotator/ui/hooks/useAIChat'; +export type { AIChatEntry, PendingPermission }; interface UseAIChatOptions { patch: string; @@ -24,303 +8,14 @@ interface UseAIChatOptions { reasoningEffort?: string | null; } -interface AskParams { - prompt: string; - filePath?: string; - lineStart?: number; - lineEnd?: number; - side?: 'old' | 'new'; - selectedCode?: string; -} - export function useAIChat({ patch, providerId, model, reasoningEffort }: UseAIChatOptions) { - const [sessionId, setSessionId] = useState(null); - const [messages, setMessages] = useState([]); - const [isCreatingSession, setIsCreatingSession] = useState(false); - const [isStreaming, setIsStreaming] = useState(false); - const [error, setError] = useState(null); - const [permissionRequests, setPermissionRequests] = useState([]); - - const abortRef = useRef(null); - const sessionIdRef = useRef(null); - - // Keep ref in sync for use inside async callbacks - sessionIdRef.current = sessionId; - - const createSession = useCallback(async (signal: AbortSignal): Promise => { - setIsCreatingSession(true); - try { - const res = await fetch('/api/ai/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - context: { - mode: 'code-review', - review: { patch }, - }, - ...(providerId && { providerId }), - ...(model && { model }), - ...(reasoningEffort && { reasoningEffort }), - }), - signal, - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({ error: 'Failed to create AI session' })); - throw new Error(data.error || `HTTP ${res.status}`); - } - - const data = await res.json() as { sessionId: string }; - setSessionId(data.sessionId); - return data.sessionId; - } finally { - setIsCreatingSession(false); - } - }, [patch, providerId, model, reasoningEffort]); - - const ask = useCallback(async (params: AskParams) => { - // Abort any in-flight request - if (abortRef.current) { - abortRef.current.abort(); - } - - const controller = new AbortController(); - abortRef.current = controller; - setError(null); - - const questionId = generateId(); - const question: AIQuestion = { - id: questionId, - prompt: params.prompt, - filePath: params.filePath, - lineStart: params.lineStart, - lineEnd: params.lineEnd, - side: params.side, - selectedCode: params.selectedCode, - createdAt: Date.now(), - }; - - const response: AIResponse = { - questionId, - text: '', - isStreaming: true, - createdAt: Date.now(), - }; - - // Add the message pair immediately so the UI shows the question - setMessages(prev => [...prev, { question, response }]); - setIsStreaming(true); - - try { - // Lazy session creation - let sid = sessionIdRef.current; - if (!sid) { - sid = await createSession(controller.signal); - } - - // Build the prompt with context based on scope - let fullPrompt = params.prompt; - if (params.filePath && params.lineStart != null && params.lineEnd != null) { - // Line-scoped - const lineRef = params.lineStart === params.lineEnd - ? `line ${params.lineStart}` - : `lines ${params.lineStart}-${params.lineEnd}`; - const sideLabel = params.side === 'new' ? 'new (added)' : 'old (removed)'; - const codeBlock = params.selectedCode - ? `\n\`\`\`\n${params.selectedCode}\n\`\`\`\n` - : ''; - fullPrompt = `Re: ${params.filePath}, ${lineRef} (${sideLabel} side)${codeBlock}\n${params.prompt}`; - } else if (params.filePath) { - // File-scoped - fullPrompt = `Re: ${params.filePath} (entire file)\n\n${params.prompt}`; - } - // else: general — use prompt as-is - - // Start SSE stream - const res = await fetch('/api/ai/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sid, prompt: fullPrompt }), - signal: controller.signal, - }); - - if (!res.ok || !res.body) { - const data = await res.json().catch(() => ({ error: 'Query failed' })); - throw new Error(data.error || `HTTP ${res.status}`); - } - - // Parse SSE stream - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n'); - buffer = lines.pop()!; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6); - if (data === '[DONE]') continue; - - try { - const msg = JSON.parse(data); - - if (msg.type === 'text_delta') { - setMessages(prev => - prev.map(m => - m.question.id === questionId - ? { ...m, response: { ...m.response, text: m.response.text + msg.delta } } - : m - ) - ); - } else if (msg.type === 'text') { - // Complete text from assistant message — only use if we have no - // streaming content yet (deltas already accumulated the same text). - setMessages(prev => - prev.map(m => - m.question.id === questionId && !m.response.text - ? { ...m, response: { ...m.response, text: msg.text } } - : m - ) - ); - } else if (msg.type === 'permission_request') { - setPermissionRequests(prev => [...prev, { - requestId: msg.requestId, - toolName: msg.toolName, - toolInput: msg.toolInput, - title: msg.title, - displayName: msg.displayName, - description: msg.description, - toolUseId: msg.toolUseId, - }]); - } else if (msg.type === 'error') { - setMessages(prev => - prev.map(m => - m.question.id === questionId - ? { ...m, response: { ...m.response, error: msg.error, isStreaming: false } } - : m - ) - ); - setError(msg.error); - } else if (msg.type === 'result') { - setMessages(prev => - prev.map(m => { - if (m.question.id !== questionId) return m; - const resultText = msg.result ?? ''; - return { - ...m, - response: { - ...m.response, - text: m.response.text || resultText, - isStreaming: false, - }, - }; - }) - ); - } - } catch { - // Skip unparseable lines - } - } - } - - // Finalize if not already done - setMessages(prev => - prev.map(m => - m.question.id === questionId && m.response.isStreaming - ? { ...m, response: { ...m.response, isStreaming: false } } - : m - ) - ); - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return; - - const message = err instanceof Error ? err.message : String(err); - setError(message); - setMessages(prev => - prev.map(m => - m.question.id === questionId - ? { ...m, response: { ...m.response, error: message, isStreaming: false } } - : m - ) - ); - } finally { - if (abortRef.current === controller) { - setIsStreaming(false); - abortRef.current = null; - } - } - }, [createSession]); - - const abort = useCallback(() => { - if (abortRef.current) { - abortRef.current.abort(); - abortRef.current = null; - setIsStreaming(false); - } - // Also tell the server to abort - if (sessionIdRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionIdRef.current }), - }).catch(() => {}); - } - }, []); - - const respondToPermission = useCallback((requestId: string, allow: boolean) => { - if (!sessionIdRef.current) return; - - // Update the permission request state - setPermissionRequests(prev => - prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p) - ); - - // Send the decision to the server - fetch('/api/ai/permission', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionIdRef.current, - requestId, - allow, - }), - }).catch(() => {}); - }, []); - - const resetSession = useCallback(() => { - if (abortRef.current) { - abortRef.current.abort(); - abortRef.current = null; - } - setSessionId(null); - setIsStreaming(false); - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (abortRef.current) { - abortRef.current.abort(); - } - }; - }, []); - - return { - messages, - isCreatingSession, - isStreaming, - error, - permissionRequests, - respondToPermission, - ask, - abort, - resetSession, - sessionId, - }; + return useSharedAIChat({ + context: { + mode: 'code-review', + review: { patch }, + }, + providerId, + model, + reasoningEffort, + }); } diff --git a/packages/server/ai-runtime.ts b/packages/server/ai-runtime.ts new file mode 100644 index 000000000..a662cfa20 --- /dev/null +++ b/packages/server/ai-runtime.ts @@ -0,0 +1,102 @@ +import { + createAIEndpoints, + createProvider, + ProviderRegistry, + SessionManager, + type AIEndpoints, + type PiSDKConfig, +} from "@plannotator/ai"; + +export interface AIRuntime { + endpoints: AIEndpoints; + dispose: () => void; +} + +export const AI_QUERY_ENDPOINT = "/api/ai/query"; + +interface CreateAIRuntimeOptions { + cwd?: string; + getCwd?: () => string; +} + +export async function createAIRuntime(options: CreateAIRuntimeOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const registry = new ProviderRegistry(); + const sessionManager = new SessionManager(); + + try { + await import("@plannotator/ai/providers/claude-agent-sdk"); + const claudePath = Bun.which("claude"); + const provider = await createProvider({ + type: "claude-agent-sdk", + cwd, + ...(claudePath && { claudeExecutablePath: claudePath }), + }); + registry.register(provider); + } catch { + // Claude SDK not available. + } + + try { + await import("@plannotator/ai/providers/codex-sdk"); + await import("@openai/codex-sdk"); + const codexPath = Bun.which("codex"); + const provider = await createProvider({ + type: "codex-sdk", + cwd, + ...(codexPath && { codexExecutablePath: codexPath }), + }); + registry.register(provider); + } catch { + // Codex SDK not available. + } + + try { + const { PiSDKProvider } = await import("@plannotator/ai/providers/pi-sdk"); + const piPath = Bun.which("pi"); + if (piPath) { + const provider = await createProvider({ + type: "pi-sdk", + cwd, + piExecutablePath: piPath, + } as PiSDKConfig); + if (provider instanceof PiSDKProvider) { + await provider.fetchModels(); + } + registry.register(provider); + } + } catch { + // Pi not available. + } + + try { + const { OpenCodeProvider } = await import("@plannotator/ai/providers/opencode-sdk"); + const opencodePath = Bun.which("opencode"); + if (opencodePath) { + const provider = await createProvider({ + type: "opencode-sdk", + cwd, + }); + if (provider instanceof OpenCodeProvider) { + await provider.fetchModels(); + } + registry.register(provider); + } + } catch { + // OpenCode not available. + } + + const endpoints = createAIEndpoints({ + registry, + sessionManager, + getCwd: options.getCwd, + }); + + return { + endpoints, + dispose: () => { + sessionManager.disposeAll(); + registry.disposeAll(); + }, + }; +} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 3029a0e9f..94a7fc6cd 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -22,6 +22,8 @@ import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; import { dirname, resolve as resolvePath } from "path"; import { isWSL } from "./browser"; +import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime"; +import type { AIEndpoints } from "@plannotator/ai"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -129,6 +131,7 @@ export async function startAnnotateServer( : renderHtml && rawHtml ? rawHtml : markdown; const draftKey = contentHash(draftSource); const externalAnnotations = createExternalAnnotationHandler("plan"); + const aiRuntime = await createAIRuntime(); // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(); @@ -259,6 +262,17 @@ export async function startAnnotateServer( }); if (externalResponse) return externalResponse; + if (url.pathname.startsWith("/api/ai/")) { + const handler = aiRuntime.endpoints[url.pathname as keyof AIEndpoints]; + if (handler) { + if (url.pathname === AI_QUERY_ENDPOINT) { + server.timeout(req, 0); + } + return handler(req); + } + return Response.json({ error: "Not found" }, { status: 404 }); + } + // API: Exit annotation session without feedback if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); @@ -355,6 +369,9 @@ export async function startAnnotateServer( url: serverUrl, isRemote, waitForDecision: () => decisionPromise, - stop: () => server.stop(), + stop: () => { + aiRuntime.dispose(); + server.stop(); + }, }; } diff --git a/packages/server/index.ts b/packages/server/index.ts index 8b1356a49..a2c68b0b3 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -51,6 +51,8 @@ import { warmFileListCache } from "@plannotator/shared/resolve-file"; import { createEditorAnnotationHandler } from "./editor-annotations"; import { createExternalAnnotationHandler } from "./external-annotations"; import { isWSL } from "./browser"; +import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime"; +import type { AIEndpoints } from "@plannotator/ai"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -154,6 +156,7 @@ export async function startPlannotatorServer( const draftKey = mode !== "archive" ? contentHash(plan) : ""; const editorAnnotations = mode !== "archive" ? createEditorAnnotationHandler() : null; const externalAnnotations = mode !== "archive" ? createExternalAnnotationHandler("plan") : null; + const aiRuntime = mode !== "archive" ? await createAIRuntime() : null; const slug = mode !== "archive" ? generateSlug(plan) : ""; // Lazy cache for in-session archive browsing (plan review sidebar tab) @@ -412,6 +415,23 @@ export async function startPlannotatorServer( }); if (externalResponse) return externalResponse; + if (url.pathname.startsWith("/api/ai/")) { + if (!aiRuntime) { + if (url.pathname.slice("/api/ai/".length) === "capabilities" && req.method === "GET") { + return Response.json({ available: false, providers: [] }); + } + return Response.json({ error: "AI backend not available" }, { status: 503 }); + } + const handler = aiRuntime.endpoints[url.pathname as keyof AIEndpoints]; + if (handler) { + if (url.pathname === AI_QUERY_ENDPOINT) { + server.timeout(req, 0); + } + return handler(req); + } + return Response.json({ error: "Not found" }, { status: 404 }); + } + // API: Save to notes (decoupled from approve/deny) if (url.pathname === "/api/save-notes" && req.method === "POST") { const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {}; @@ -620,6 +640,9 @@ export async function startPlannotatorServer( isRemote, waitForDecision: () => decisionPromise, ...(donePromise && { waitForDone: () => donePromise }), - stop: () => server.stop(), + stop: () => { + aiRuntime?.dispose(); + server.stop(); + }, }; } diff --git a/packages/server/review.ts b/packages/server/review.ts index c31646398..a084b98a3 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -46,7 +46,8 @@ import { import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "./tour/tour-review"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; import { type PRMetadata, type PRReviewFileComment, type PRStackTree, type PRListItem, fetchPR, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, fetchPRStack, fetchPRList, getPRUser, parsePRUrl, prRefFromMetadata, isSameProject, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; -import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; +import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime"; +import type { AIEndpoints } from "@plannotator/ai"; import { isWSL } from "./browser"; import { handleCodeNavResolve, extractChangedFiles } from "./code-nav"; @@ -331,86 +332,8 @@ export async function startReviewServer( }, }); - // AI provider setup (graceful — AI features degrade if SDK unavailable) - const aiRegistry = new ProviderRegistry(); - const aiSessionManager = new SessionManager(); - let aiEndpoints: AIEndpoints | null = null; - - // Try Claude Agent SDK - try { - await import("@plannotator/ai/providers/claude-agent-sdk"); - const claudePath = Bun.which("claude"); - const provider = await createProvider({ - type: "claude-agent-sdk", - cwd: process.cwd(), - ...(claudePath && { claudeExecutablePath: claudePath }), - }); - aiRegistry.register(provider); - } catch { - // Claude SDK not available - } - - // Try Codex SDK - try { - await import("@plannotator/ai/providers/codex-sdk"); - // Eagerly verify the SDK is importable so we don't advertise a broken provider. - await import("@openai/codex-sdk"); - const codexPath = Bun.which("codex"); - const provider = await createProvider({ - type: "codex-sdk", - cwd: process.cwd(), - ...(codexPath && { codexExecutablePath: codexPath }), - }); - aiRegistry.register(provider); - } catch { - // Codex SDK not available - } - - // Try Pi - try { - const { PiSDKProvider } = await import("@plannotator/ai/providers/pi-sdk"); - const piPath = Bun.which("pi"); - if (piPath) { - const provider = await createProvider({ - type: "pi-sdk", - cwd: process.cwd(), - piExecutablePath: piPath, - } as PiSDKConfig); - if (provider instanceof PiSDKProvider) { - await provider.fetchModels(); - } - aiRegistry.register(provider); - } - } catch { - // Pi not available - } - - // Try OpenCode - try { - const { OpenCodeProvider } = await import("@plannotator/ai/providers/opencode-sdk"); - const opencodePath = Bun.which("opencode"); - if (opencodePath) { - const provider = await createProvider({ - type: "opencode-sdk", - cwd: process.cwd(), - }); - if (provider instanceof OpenCodeProvider) { - await provider.fetchModels(); - } - aiRegistry.register(provider); - } - } catch { - // OpenCode not available - } - - // Create endpoints if any provider registered - if (aiRegistry.size > 0) { - aiEndpoints = createAIEndpoints({ - registry: aiRegistry, - sessionManager: aiSessionManager, - getCwd: resolveAgentCwd, - }); - } + // AI provider setup (graceful — capabilities report unavailable if no provider is registered) + const aiRuntime = await createAIRuntime({ getCwd: resolveAgentCwd }); const isRemote = isRemoteSession(); const configuredPort = getServerPort(); @@ -1137,9 +1060,14 @@ export async function startReviewServer( } // AI endpoints - if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { - const handler = aiEndpoints[url.pathname as keyof AIEndpoints]; - if (handler) return handler(req); + if (url.pathname.startsWith("/api/ai/")) { + const handler = aiRuntime.endpoints[url.pathname as keyof AIEndpoints]; + if (handler) { + if (url.pathname === AI_QUERY_ENDPOINT) { + server.timeout(req, 0); + } + return handler(req); + } return Response.json({ error: "Not found" }, { status: 404 }); } @@ -1202,8 +1130,7 @@ export async function startReviewServer( stop: () => { process.removeListener("exit", exitHandler); agentJobs.killAll(); - aiSessionManager.disposeAll(); - aiRegistry.disposeAll(); + aiRuntime.dispose(); server.stop(); // Invoke cleanup callback (e.g., remove temp worktree) if (options.onCleanup) { diff --git a/packages/shared/agents.ts b/packages/shared/agents.ts index 68e78c235..87dcc1687 100644 --- a/packages/shared/agents.ts +++ b/packages/shared/agents.ts @@ -2,20 +2,28 @@ * Centralized agent configuration — single source of truth for all supported agents. * * To add a new agent: - * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes) + * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, + * optional AI provider types) * 2. If detection is via environment variable, add it to the detection chain * in apps/hook/server/index.ts (detectedOrigin constant) * 3. That's it — all UI components read from this config automatically */ +type AgentConfigEntry = { + name: string; + badge: string; + /** AI provider type(s) that naturally match this origin, in preference order. */ + aiProviderTypes?: readonly string[]; +}; + export const AGENT_CONFIG = { - 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400' }, - 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400' }, + 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, + 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, - 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400' }, - 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400' }, + 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, + 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, -} as const; +} as const satisfies Record; /** All recognized origin values. */ export type Origin = keyof typeof AGENT_CONFIG; @@ -31,3 +39,12 @@ export function getAgentBadge(origin: Origin | null | undefined): string { if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; return 'bg-zinc-500/20 text-zinc-400'; } + +/** Resolve an origin to matching AI provider types, in preference order. */ +export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { + if (origin && origin in AGENT_CONFIG) { + const config = AGENT_CONFIG[origin as Origin]; + return 'aiProviderTypes' in config ? config.aiProviderTypes : []; + } + return []; +} diff --git a/packages/ui/aiProvider.test.ts b/packages/ui/aiProvider.test.ts new file mode 100644 index 000000000..d1fc77e22 --- /dev/null +++ b/packages/ui/aiProvider.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test'; +import { + findOriginAIProvider, + resolveAIProviderSelection, + type AIProviderOption, + type AIProviderSettings, +} from './utils/aiProvider'; + +const providers: AIProviderOption[] = [ + { + id: 'claude-local', + name: 'claude-agent-sdk', + models: [ + { id: 'claude-default', label: 'Claude Default', default: true }, + { id: 'claude-alt', label: 'Claude Alt' }, + ], + }, + { + id: 'codex-local', + name: 'codex-sdk', + models: [ + { id: 'codex-default', label: 'Codex Default', default: true }, + { id: 'codex-alt', label: 'Codex Alt' }, + ], + }, + { id: 'opencode-sdk', name: 'opencode-sdk' }, +]; + +const settings = (overrides: Partial = {}): AIProviderSettings => ({ + providerId: null, + preferredModels: {}, + providerByOrigin: {}, + ...overrides, +}); + +describe('AI provider origin defaults', () => { + it('matches a detected origin by provider type even when registry IDs are custom', () => { + expect(findOriginAIProvider(providers, 'claude-code')?.id).toBe('claude-local'); + expect(findOriginAIProvider(providers, 'codex')?.id).toBe('codex-local'); + expect(findOriginAIProvider(providers, 'opencode')?.id).toBe('opencode-sdk'); + }); + + it('uses the origin-matched provider before the global saved provider', () => { + const selection = resolveAIProviderSelection({ + providers, + origin: 'codex', + settings: settings({ providerId: 'claude-local' }), + }); + + expect(selection.providerId).toBe('codex-local'); + expect(selection.model).toBe('codex-default'); + }); + + it('uses per-origin saved provider choices before the automatic origin match', () => { + const selection = resolveAIProviderSelection({ + providers, + origin: 'codex', + settings: settings({ + providerByOrigin: { codex: 'claude-local' }, + preferredModels: { 'claude-local': 'claude-alt' }, + }), + }); + + expect(selection.providerId).toBe('claude-local'); + expect(selection.model).toBe('claude-alt'); + }); + + it('falls back to server default when an origin has no matching provider', () => { + const selection = resolveAIProviderSelection({ + providers, + origin: 'gemini-cli', + settings: settings(), + serverDefaultProvider: 'codex-local', + }); + + expect(selection.providerId).toBe('codex-local'); + }); +}); diff --git a/packages/ui/components/AISettingsTab.tsx b/packages/ui/components/AISettingsTab.tsx index b2cd4901d..098beae69 100644 --- a/packages/ui/components/AISettingsTab.tsx +++ b/packages/ui/components/AISettingsTab.tsx @@ -1,7 +1,14 @@ import type React from 'react'; import { getProviderMeta } from './ProviderIcons'; -import { saveAIProviderSettings, savePreferredModel, getAIProviderSettings } from '../utils/aiProvider'; +import { + getAIProviderSettings, + resolveAIModelForProvider, + resolveAIProviderSelection, + saveAIProviderSelection, + savePreferredModel, +} from '../utils/aiProvider'; import { useState } from 'react'; +import type { Origin } from '@plannotator/shared/agents'; interface AIProviderModel { id: string; @@ -19,23 +26,28 @@ interface AIProvider { interface AISettingsTabProps { providers: AIProvider[]; selectedProviderId: string | null; + origin?: Origin | null; onProviderChange: (providerId: string | null) => void; } export const AISettingsTab: React.FC = ({ providers, selectedProviderId, + origin, onProviderChange, }) => { - const effectiveSelection = selectedProviderId ?? providers[0]?.id ?? null; + const settings = getAIProviderSettings(); + const originDefault = resolveAIProviderSelection({ providers, origin, settings }).providerId; + const effectiveSelection = selectedProviderId ?? originDefault ?? providers[0]?.id ?? null; const [preferredModels, setPreferredModels] = useState>(() => getAIProviderSettings().preferredModels ); const handleSelectProvider = (providerId: string) => { onProviderChange(providerId); - const settings = getAIProviderSettings(); - saveAIProviderSettings({ ...settings, providerId }); + const provider = providers.find(p => p.id === providerId) ?? null; + const model = resolveAIModelForProvider(provider, getAIProviderSettings().preferredModels); + saveAIProviderSelection({ providerId, model, origin }); }; const handleModelChange = (providerId: string, modelId: string) => { diff --git a/packages/ui/components/AISetupDialog.tsx b/packages/ui/components/AISetupDialog.tsx deleted file mode 100644 index 119a8ff39..000000000 --- a/packages/ui/components/AISetupDialog.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { useState } from 'react'; -import { createPortal } from 'react-dom'; -import { getProviderMeta } from './ProviderIcons'; -import { saveAIProviderSettings, savePreferredModel, getAIProviderSettings } from '../utils/aiProvider'; -import { markAISetupDone } from '../utils/aiSetup'; - -interface AIProviderModel { - id: string; - label: string; - default?: boolean; -} - -interface AIProvider { - id: string; - name: string; - capabilities: Record; - models?: AIProviderModel[]; -} - -interface AISetupDialogProps { - isOpen: boolean; - providers: AIProvider[]; - onComplete: (providerId: string) => void; -} - -export const AISetupDialog: React.FC = ({ - isOpen, - providers, - onComplete, -}) => { - const [selectedId, setSelectedId] = useState(null); - const [preferredModels, setPreferredModels] = useState>(() => - getAIProviderSettings().preferredModels - ); - - if (!isOpen || providers.length === 0) return null; - - const effectiveId = selectedId ?? providers[0]?.id ?? null; - - const handleSelectProvider = (id: string) => { - setSelectedId(id); - }; - - const handleModelChange = (providerId: string, modelId: string) => { - savePreferredModel(providerId, modelId); - setPreferredModels(prev => ({ ...prev, [providerId]: modelId })); - }; - - const handleDone = () => { - if (!effectiveId) return; - const settings = getAIProviderSettings(); - saveAIProviderSettings({ ...settings, providerId: effectiveId }); - markAISetupDone(); - onComplete(effectiveId); - }; - - return createPortal( -
-
- {/* Header */} -
-
-
- - - -
-

AI-Assisted Code Review

-
-

- Ask questions about code changes inline or in a side chat. Select lines in a diff, - click the sparkle, and get streaming answers with full diff context. -

-
- - {/* Provider grid */} -
-
Choose your default provider
-
- {providers.map(p => { - const meta = getProviderMeta(p.name); - const Icon = meta.icon; - const isSelected = effectiveId === p.id; - return ( - - ); - })} -
- - {/* Model selector for selected provider */} - {effectiveId && (() => { - const provider = providers.find(p => p.id === effectiveId); - const models = provider?.models ?? []; - if (models.length <= 1) return null; - const defaultModel = models.find(m => m.default) ?? models[0]; - const selectedModel = preferredModels[effectiveId] ?? defaultModel?.id ?? ''; - return ( -
- -
- ); - })()} -
- - {/* Footer */} -
-

- Providers are detected from installed CLI tools. - You must be authenticated with each CLI independently.{' '} - Learn more -

- -
-
-
, - document.body - ); -}; diff --git a/packages/ui/components/CommentPopover.tsx b/packages/ui/components/CommentPopover.tsx index c1307910a..23d34c7d8 100644 --- a/packages/ui/components/CommentPopover.tsx +++ b/packages/ui/components/CommentPopover.tsx @@ -4,6 +4,14 @@ import type { ImageAttachment } from '../types'; import { AttachmentsButton } from './AttachmentsButton'; import { submitHint } from '../utils/platform'; import { useDraggable } from '../hooks/useDraggable'; +import { SparklesIcon } from './SparklesIcon'; + +export interface CommentAskAIContext { + kind: 'general' | 'selection'; + label?: string; + text?: string; + sourcePath?: string; +} interface CommentPopoverProps { /** Element to anchor the popover near (re-reads position on scroll) */ @@ -28,6 +36,10 @@ interface CommentPopoverProps { allowImages?: boolean; /** Whether submitting empty text is allowed, for editors that support clearing. */ allowEmptySubmit?: boolean; + /** Optional Ask AI action. Absent by default so existing comment surfaces are unchanged. */ + onAskAI?: (question: string, context: CommentAskAIContext) => void; + askAIContext?: CommentAskAIContext; + askAIDisabled?: boolean; } const MAX_POPOVER_WIDTH = 384; @@ -75,6 +87,9 @@ export const CommentPopover: React.FC = ({ draftKey, allowImages = true, allowEmptySubmit = false, + onAskAI, + askAIContext, + askAIDisabled = false, }) => { const [mode, setMode] = useState<'popover' | 'dialog'>('popover'); const initialDraft = draftKey ? draftStore.get(draftKey) : undefined; @@ -157,6 +172,18 @@ export const CommentPopover: React.FC = ({ } }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText]); + const handleAskAI = useCallback(() => { + const question = text.trim(); + if (!question || !onAskAI) { + textareaRef.current?.focus(); + return; + } + onAskAI(question, askAIContext ?? { + kind: isGlobal ? 'general' : 'selection', + text: contextText, + }); + }, [askAIContext, contextText, isGlobal, onAskAI, text]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); @@ -183,6 +210,7 @@ export const CommentPopover: React.FC = ({ text.trim().length > 0 || (allowImages && images.length > 0) || (allowEmptySubmit && initialText.trim().length > 0); + const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0; if (mode === 'dialog') { return createPortal( @@ -255,6 +283,17 @@ export const CommentPopover: React.FC = ({ )}
+ {onAskAI && ( + + )} {submitHint}
+ {onAskAI && ( + + )} {submitHint} + +
+ + + , + document.body + ); +}; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..accc8db89 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -61,7 +61,7 @@ import { KeyboardShortcuts } from './KeyboardShortcuts'; import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels'; import { ThemeTab } from './ThemeTab'; import { isMac, modKey, altKey } from '../utils/platform'; -import { getAIProviderSettings } from '../utils/aiProvider'; +import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider'; import { AISettingsTab } from './AISettingsTab'; import { HooksTab } from './settings/HooksTab'; import { OverlayScrollArea } from './OverlayScrollArea'; @@ -84,7 +84,7 @@ interface SettingsProps { externalOpen?: boolean; onExternalClose?: () => void; /** Available AI providers (from /api/ai/capabilities). */ - aiProviders?: Array<{ id: string; name: string; capabilities: Record }>; + aiProviders?: Array<{ id: string; name: string; capabilities: Record; models?: Array<{ id: string; label: string; default?: boolean }> }>; /** Git user name from `git config user.name`, for quick identity set */ gitUser?: string; } @@ -696,7 +696,8 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange setAutoCloseDelayState(getAutoCloseDelay()); setDefaultNotesApp(getDefaultNotesApp()); setQuickLabelsState(getQuickLabels()); - setAiProvider(getAIProviderSettings().providerId); + const aiSettings = getAIProviderSettings(); + setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId); setFileBrowserSettings(getFileBrowserSettings()); // Validate agent setting when dialog opens @@ -704,7 +705,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange setAgentWarning(getAgentWarning()); } } - }, [showDialog, availableAgents, origin, getAgentWarning]); + }, [showDialog, availableAgents, origin, getAgentWarning, aiProviders.length]); useEffect(() => { if (!showDialog) return; @@ -1640,6 +1641,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )} diff --git a/packages/review-editor/components/SparklesIcon.tsx b/packages/ui/components/SparklesIcon.tsx similarity index 97% rename from packages/review-editor/components/SparklesIcon.tsx rename to packages/ui/components/SparklesIcon.tsx index 7897148c7..9974da437 100644 --- a/packages/review-editor/components/SparklesIcon.tsx +++ b/packages/ui/components/SparklesIcon.tsx @@ -6,7 +6,7 @@ interface SparklesIconProps { } /** - * Three-star sparkle icon adapted from the automations branch. + * Three-star sparkle icon used for Ask AI entry points. * Uses currentColor for fill so it inherits from parent text color. */ export const SparklesIcon: React.FC = ({ className = 'w-3 h-3', animated = false }) => ( diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 6f5c917e9..df0b4e938 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -33,7 +33,7 @@ class ToolbarErrorBoundary extends React.Component< } } -import { CommentPopover } from './CommentPopover'; +import { CommentPopover, type CommentAskAIContext } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; import { GraphvizBlock } from './GraphvizBlock'; @@ -96,6 +96,7 @@ interface ViewerProps { // Checkbox toggle props onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; + onAskAI?: (question: string, context: CommentAskAIContext) => void; } export interface ViewerHandle { @@ -170,6 +171,7 @@ export const Viewer = forwardRef(({ sourceInfo, onToggleCheckbox, checkboxOverrides, + onAskAI, }, ref) => { const [copied, setCopied] = useState(false); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); @@ -200,6 +202,7 @@ export const Viewer = forwardRef(({ const [viewerCommentPopover, setViewerCommentPopover] = useState<{ anchorEl: HTMLElement; contextText: string; + selectedText?: string; initialText?: string; isGlobal: boolean; codeBlock?: { block: Block; element: HTMLElement }; @@ -263,6 +266,7 @@ export const Viewer = forwardRef(({ setViewerCommentPopover({ anchorEl: element, contextText: (codeEl.textContent || '').slice(0, 80), + selectedText: codeEl.textContent || '', isGlobal: false, codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, }); @@ -479,6 +483,7 @@ export const Viewer = forwardRef(({ setViewerCommentPopover({ anchorEl: hoveredCodeBlock.element, contextText: codeText.slice(0, 80), + selectedText: codeText, initialText: initialChar, isGlobal: false, codeBlock: hoveredCodeBlock, @@ -808,15 +813,22 @@ export const Viewer = forwardRef(({ {/* Comment popover — hook handles text selection, Viewer handles global + code block */} {hookCommentPopover && ( - - )} + + )} {viewerCommentPopover && ( (({ initialText={viewerCommentPopover.initialText} onSubmit={handleViewerCommentSubmit} onClose={handleViewerCommentClose} + onAskAI={onAskAI} + askAIContext={{ + kind: viewerCommentPopover.isGlobal ? 'general' : 'selection', + label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block', + text: viewerCommentPopover.selectedText, + sourcePath: linkedDocInfo?.filepath ?? sourceInfo, + }} /> )} @@ -925,4 +944,3 @@ function groupBlocks(blocks: Block[]): RenderGroup[] { return groups; } - diff --git a/packages/ui/components/ai/AIProviderBar.tsx b/packages/ui/components/ai/AIProviderBar.tsx new file mode 100644 index 000000000..19f41435c --- /dev/null +++ b/packages/ui/components/ai/AIProviderBar.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { getProviderMeta } from '../ProviderIcons'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProviderInfo { + id: string; + name: string; + models?: AIProviderModel[]; +} + +interface AIProviderBarProps { + providers: AIProviderInfo[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort?: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange?: (effort: string | null) => void; +} + +const REASONING_EFFORTS = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Max' }, +] as const; + +export const AIProviderBar: React.FC = ({ + providers, + selectedProviderId, + selectedModel, + selectedReasoningEffort, + onProviderChange, + onModelChange, + onReasoningEffortChange, +}) => { + if (providers.length === 0) { + return ( +
+ No AI providers available +
+ ); + } + + const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0]; + const effectiveProviderId = currentProvider?.id ?? ''; + const models = currentProvider?.models ?? []; + const defaultModel = models.find(m => m.default) ?? models[0]; + const effectiveModel = selectedModel ?? defaultModel?.id ?? ''; + const meta = getProviderMeta(currentProvider?.name ?? 'AI'); + const Icon = meta.icon; + const showReasoningEffort = currentProvider?.name === 'codex-sdk' && !!onReasoningEffortChange; + + return ( +
+ + + + {models.length > 0 && ( + + )} + + {showReasoningEffort && ( + + )} +
+ ); +}; diff --git a/packages/ui/components/ai/DocumentAIChatPanel.tsx b/packages/ui/components/ai/DocumentAIChatPanel.tsx new file mode 100644 index 000000000..859c49628 --- /dev/null +++ b/packages/ui/components/ai/DocumentAIChatPanel.tsx @@ -0,0 +1,275 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat'; +import { OverlayScrollArea } from '../OverlayScrollArea'; +import { SparklesIcon } from '../SparklesIcon'; +import { AIProviderBar } from './AIProviderBar'; +import { submitHint } from '../../utils/platform'; + +interface AIProviderInfo { + id: string; + name: string; + models?: Array<{ id: string; label: string; default?: boolean }>; +} + +interface DocumentAIChatPanelProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + 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; +} + +function renderChatMarkdown(text: string): React.ReactNode { + const html = marked.parse(text, { async: false, breaks: true }) as string; + const clean = DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'a', 'blockquote', + ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], + }); + + return
; +} + +function formatRelativeTime(ts: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (seconds < 60) return 'now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function truncate(text: string, max = 180): string { + if (text.length <= max) return text; + return `${text.slice(0, max).trimEnd()}...`; +} + +export const DocumentAIChatPanel: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, +}) => { + const scrollRef = useRef(null); + const [generalInput, setGeneralInput] = useState(''); + + useEffect(() => { + if (!scrollRef.current) return; + const last = scrollRef.current.querySelector('[data-ai-message]:last-child'); + last?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [messages.length]); + + const handleGeneralSubmit = useCallback(() => { + const question = generalInput.trim(); + if (!question || !onAskGeneral) return; + onAskGeneral(question); + setGeneralInput(''); + }, [generalInput, onAskGeneral]); + + return ( +
+ +
+ {messages.length === 0 && !isCreatingSession && ( +
+
+ +
+

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

+
+ )} + + {isCreatingSession && messages.length === 0 && ( +
+ Starting AI session... +
+ )} + + {permissionRequests.filter(p => !p.decided).map(permission => ( + {})} + /> + ))} + + {messages.map(entry => ( + + ))} +
+
+ + onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + onReasoningEffortChange={(reasoningEffort) => onAIConfigChange?.({ reasoningEffort })} + /> + + {onAskGeneral && ( + + )} +
+ ); +}; + +const DocumentQAPair = memo<{ entry: AIChatEntry }>(({ entry }) => { + const { question, response } = entry; + const renderedResponse = useMemo( + () => response.text ? renderChatMarkdown(response.text) : null, + [response.text], + ); + const scope = question.scope; + + return ( +
+
+
+
+ {scope?.kind === 'selection' && ( + + selection + + )} + {scope?.label && ( + + {scope.label} + + )} +
+ + {formatRelativeTime(question.createdAt)} + +
+ {scope?.text && ( +

+ {truncate(scope.text)} +

+ )} +

{question.prompt}

+
+ +
+ {response.error ? ( +

{response.error}

+ ) : response.text ? ( +
+ {renderedResponse} + {response.isStreaming && } +
+ ) : response.isStreaming ? ( + + Thinking... + + ) : null} +
+
+ ); +}); + +const PermissionCard: React.FC<{ + permission: PendingPermission; + onRespond: (requestId: string, allow: boolean) => void; +}> = ({ permission, onRespond }) => { + return ( +
+

+ Permission Request +

+

+ {permission.title || permission.displayName || permission.toolName} +

+ {permission.description && ( +

{permission.description}

+ )} +
+ + +
+
+ ); +}; + +const GeneralInput: React.FC<{ + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + disabled?: boolean; +}> = ({ value, onChange, onSubmit, disabled }) => { + const textareaRef = useRef(null); + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, [value]); + + return ( +
+
+