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..84e86bb3c --- /dev/null +++ b/apps/pi-extension/server/ai-runtime.ts @@ -0,0 +1,171 @@ +import { execFileSync } 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 { + const bin = process.platform === "win32" ? "where" : "which"; + const output = execFileSync(bin, [cmd], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? 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(); + const modelDiscovery: Promise[] = []; + + 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) { + modelDiscovery.push( + (provider as { fetchModels: () => Promise }) + .fetchModels() + .catch(() => {}), + ); + } + 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) { + modelDiscovery.push( + (provider as { fetchModels: () => Promise }) + .fetchModels() + .catch(() => {}), + ); + } + registry.register(provider); + } + } catch { + // OpenCode not available. + } + + return { + endpoints: ai.createAIEndpoints({ + registry, + sessionManager, + getCwd: options.getCwd, + beforeCapabilities: async () => { + await Promise.allSettled(modelDiscovery); + }, + }), + 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..42d695653 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", () => { @@ -400,6 +418,30 @@ describe("AI endpoints", () => { expect(data.providers[0].capabilities.fork).toBe(true); }); + test("capabilities waits for pending provider discovery", async () => { + const reg = new ProviderRegistry(); + const sm = new SessionManager(); + const provider = mockProvider("pi-sdk") as AIProvider & { + models?: Array<{ id: string; label: string; default?: boolean }>; + }; + reg.register(provider); + const endpoints = createAIEndpoints({ + registry: reg, + sessionManager: sm, + beforeCapabilities: async () => { + provider.models = [{ id: "pi/model", label: "Pi Model", default: true }]; + }, + }); + + const res = await endpoints["/api/ai/capabilities"]( + new Request("http://localhost/api/ai/capabilities") + ); + const data = await res.json(); + expect(data.providers[0].models).toEqual([ + { id: "pi/model", label: "Pi Model", default: true }, + ]); + }); + test("capabilities lists multiple providers", async () => { const { reg, endpoints } = setup(); reg.register(mockProvider("claude-agent-sdk"), "claude-1"); @@ -481,6 +523,34 @@ describe("AI endpoints", () => { expect(createRes.status).toBe(200); }); + test("session creation clamps client-supplied cost controls", async () => { + const { reg, endpoints } = setup(); + let seenOptions: { maxTurns?: number; maxBudgetUsd?: number } | null = null; + reg.register({ + ...mockProvider("mock"), + async createSession(opts) { + seenOptions = opts; + return mockSession(`session-${++sessionCounter}`, null); + }, + }); + + const createRes = await endpoints["/api/ai/session"]( + new Request("http://localhost/api/ai/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: { mode: "plan-review", plan: { plan: "# Test" } }, + maxTurns: 9999, + maxBudgetUsd: 9999, + }), + }) + ); + + expect(createRes.status).toBe(200); + expect(seenOptions?.maxTurns).toBe(99); + expect(seenOptions?.maxBudgetUsd).toBe(5); + }); + test("session creation fails for unknown provider ID", async () => { const { reg, endpoints } = setup(); reg.register(mockProvider("mock")); 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/endpoints.ts b/packages/ai/endpoints.ts index fea3ae2ce..f4af9d8ad 100644 --- a/packages/ai/endpoints.ts +++ b/packages/ai/endpoints.ts @@ -61,6 +61,21 @@ export interface AIEndpointDeps { sessionManager: SessionManager; /** Resolve the current working directory for new AI sessions. */ getCwd?: () => string; + /** Optional hook to finish lazy provider capability loading before reporting capabilities. */ + beforeCapabilities?: () => Promise | void; +} + +const MAX_CLIENT_MAX_TURNS = 99; +const MAX_CLIENT_BUDGET_USD = 5; + +function clampPositiveInteger(value: unknown, max: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.max(1, Math.min(max, Math.floor(value))); +} + +function clampPositiveNumber(value: unknown, max: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + return Math.min(max, value); } /** @@ -78,10 +93,11 @@ export interface AIEndpointDeps { * ``` */ export function createAIEndpoints(deps: AIEndpointDeps) { - const { registry, sessionManager, getCwd } = deps; + const { registry, sessionManager, getCwd, beforeCapabilities } = deps; return { "/api/ai/capabilities": async (_req: Request) => { + await beforeCapabilities?.(); const defaultEntry = registry.getDefault(); const providerDetails = registry.list().map(id => { const p = registry.get(id)!; @@ -127,12 +143,14 @@ export function createAIEndpoints(deps: AIEndpointDeps) { } try { + const boundedMaxTurns = clampPositiveInteger(maxTurns, MAX_CLIENT_MAX_TURNS); + const boundedMaxBudgetUsd = clampPositiveNumber(maxBudgetUsd, MAX_CLIENT_BUDGET_USD); const options: CreateSessionOptions = { context, cwd: getCwd?.(), model, - maxTurns, - maxBudgetUsd, + ...(boundedMaxTurns !== undefined && { maxTurns: boundedMaxTurns }), + ...(boundedMaxBudgetUsd !== undefined && { maxBudgetUsd: boundedMaxBudgetUsd }), reasoningEffort, }; 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..a1ce4137e 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; } - setIsPanelOpen(prev => !prev); - }, [exitWideMode, wideModeType]); + 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; + } + 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,55 @@ 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; + + 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 +1478,159 @@ const App: React.FC = () => { return output; }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]); + const aiAnnotationsContext = useMemo( + () => hasAnyAnnotations ? annotationsOutput : undefined, + [annotationsOutput, hasAnyAnnotations], + ); + + 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 hasAIDocumentContext = + !aiDocumentMode || + annotateSource !== 'folder' || + linkedDocHook.isActive || + !!sourceFilePath; + + const aiContext = useMemo(() => { + if (!aiSessionEnabled || archive.archiveMode || goalSetupMode) return null; + if (aiDocumentMode && !hasAIDocumentContext) 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, + hasAIDocumentContext, + 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 { + messages: aiMessages, + isCreatingSession: aiIsCreatingSession, + isStreaming: aiIsStreaming, + permissionRequests: aiPermissionRequests, + respondToPermission: respondToAIPermission, + ask: askAI, + resetSession: resetAISession, + resetThread: resetAIThread, + sessionId: aiSessionId, + } = aiChat; + const canUseAI = aiAvailable && aiContext !== null; + + const aiDocumentKey = aiContext + ? `${aiDocumentMode ? 'document' : 'plan'}:${aiRenderAs}:${aiDocumentPath}:${versionInfo?.version ?? 'current'}` + : 'none'; + const previousAIDocumentKeyRef = useRef(null); + useEffect(() => { + if (!aiSessionEnabled) return; + if (previousAIDocumentKeyRef.current && previousAIDocumentKeyRef.current !== aiDocumentKey) { + resetAIThread(); + } + previousAIDocumentKeyRef.current = aiDocumentKey; + }, [aiDocumentKey, aiSessionEnabled, resetAIThread]); + + 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; + }); + resetAISession(); + }, [aiProviders, origin, resetAISession]); + + const openAIChat = useCallback(() => { + if (wideModeType !== null) { + exitWideMode({ restore: false, panelOpen: true }); + } + setRightSidebarTab('ai'); + setIsPanelOpen(true); + }, [exitWideMode, wideModeType]); + + const handleOpenAIAnnouncement = useCallback(() => { + dismissPlanAIAnnouncement(); + openAIChat(); + }, [dismissPlanAIAnnouncement, openAIChat]); + + const handleAskAI = useCallback((question: string, context?: CommentAskAIContext) => { + if (!canUseAI) return; + dismissPlanAIAnnouncement(); + openAIChat(); + askAI({ + prompt: question, + scope: context ? { + kind: context.kind, + label: context.label, + text: context.text, + sourcePath: context.sourcePath ?? aiDocumentPath, + } : undefined, + contextUpdate: aiSessionId ? aiAnnotationsContext : undefined, + }); + }, [aiAnnotationsContext, aiDocumentPath, aiSessionId, askAI, canUseAI, 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 +1912,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 && + canUseAI && + aiSessionEnabled && + isApiMode && + !isSharedSession && + !archive.archiveMode && + !goalSetupMode && + !showPermissionModeSetup && + !submitted; if (isLoading && !isSharedSession) { @@ -1695,7 +1950,10 @@ const App: React.FC = () => { origin={origin} isSubmitting={isSubmitting} isExiting={isExiting} - isPanelOpen={isPanelOpen} + isPanelOpen={isPanelOpen && rightSidebarTab === 'annotations'} + aiAvailable={canUseAI} + isAIChatOpen={isPanelOpen && rightSidebarTab === 'ai'} + aiHasMessages={aiMessages.length > 0} hasAnyAnnotations={hasAnyAnnotations} linkedDocIsActive={linkedDocHook.isActive} callbackShareUrlReady={callbackConfig ? Boolean(shareUrl || shortShareUrl) : true} @@ -1717,6 +1975,7 @@ const App: React.FC = () => { onFeedback={handleHeaderFeedback} onApprove={handleHeaderApprove} onAnnotationPanelToggle={handleAnnotationPanelToggle} + onAIChatToggle={handleAIChatToggle} onArchiveCopy={archive.copy} onArchiveDone={archive.done} onTaterModeChange={handleTaterModeChange} @@ -1964,6 +2223,7 @@ const App: React.FC = () => { onAddGlobalAttachment={handleAddGlobalAttachment} onRemoveGlobalAttachment={handleRemoveGlobalAttachment} maxWidth={annotateReaderMaxWidth} + onAskAI={canUseAI ? handleAskAI : undefined} /> ) : ( { onToggleCheckbox={checkbox.toggle} checkboxOverrides={checkbox.overrides} actionsLabelMode={actionsLabelMode} + onAskAI={canUseAI ? handleAskAI : undefined} /> )} @@ -2008,11 +2269,11 @@ const App: React.FC = () => { {/* Resize Handle */} - {isPanelOpen && wideModeType === null && !goalSetupMode && } + {isPanelOpen && wideModeType === null && !goalSetupMode && (rightSidebarTab === 'annotations' || canUseAI) && } {/* Annotation Panel */} { otherFileAnnotations={otherFileAnnotations} onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} /> + {isPanelOpen && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && canUseAI && ( + + )} @@ -2217,6 +2522,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..dde907718 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({ @@ -424,6 +426,16 @@ const ReviewApp: React.FC = () => { model: aiConfig.model, reasoningEffort: aiConfig.reasoningEffort, }); + const { + messages: aiMessages, + isCreatingSession: aiIsCreatingSession, + isStreaming: aiIsStreaming, + permissionRequests: aiPermissionRequests, + respondToPermission: respondToAIPermission, + ask: askAI, + resetSession: resetAISession, + sessionId: aiSessionId, + } = aiChat; const codeNav = useCodeNav(); @@ -464,26 +476,49 @@ 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 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 selection = resolveAIProviderSelection({ + providers: aiProviders, + origin, + settings: saved, + serverDefaultProvider: aiDefaultProvider, + }); + + if (prev.providerId === selection.providerId && prev.model === selection.model) return prev; + + 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 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]); + resetAISession(); + }, [aiProviders, origin, resetAISession]); const handleAskAI = useCallback((question: string) => { if (!pendingSelection || !files[activeFileIndex]) return; @@ -492,7 +527,7 @@ const ReviewApp: React.FC = () => { const side = pendingSelection.side === 'additions' ? 'new' : 'old'; const selectedCode = extractLinesFromPatch(files[activeFileIndex].patch, lineStart, lineEnd, side); - aiChat.ask({ + askAI({ prompt: question, filePath: files[activeFileIndex].path, lineStart, @@ -500,7 +535,7 @@ const ReviewApp: React.FC = () => { side, selectedCode: selectedCode || undefined, }); - }, [pendingSelection, files, activeFileIndex, aiChat]); + }, [activeFileIndex, askAI, files, pendingSelection]); const handleViewAIResponse = useCallback((questionId?: string) => { reviewSidebar.open('ai'); @@ -528,13 +563,13 @@ const ReviewApp: React.FC = () => { const selStart = Math.min(pendingSelection.start, pendingSelection.end); const selEnd = Math.max(pendingSelection.start, pendingSelection.end); const side = pendingSelection.side === 'additions' ? 'new' : 'old'; - return aiChat.messages.filter(m => { + return aiMessages.filter(m => { const q = m.question; return q.filePath === filePath && q.side === side && q.lineStart != null && q.lineEnd != null && q.lineStart <= selEnd && q.lineEnd >= selStart; }); - }, [pendingSelection, files, activeFileIndex, aiChat.messages]); + }, [pendingSelection, files, activeFileIndex, aiMessages]); // Click AI marker in diff → scroll sidebar to that Q&A const [scrollToQuestionId, setScrollToQuestionId] = useState(null); @@ -547,8 +582,8 @@ const ReviewApp: React.FC = () => { // General AI question from sidebar input const handleAskGeneral = useCallback((question: string) => { - aiChat.ask({ prompt: question }); - }, [aiChat.ask]); + askAI({ prompt: question }); + }, [askAI]); // Resizable panels const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' }); @@ -834,13 +869,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); @@ -1390,9 +1425,9 @@ const ReviewApp: React.FC = () => { activeSearchMatchId, activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null, aiAvailable, - aiMessages: aiChat.messages, + aiMessages, onAskAI: handleAskAI, - isAILoading: aiChat.isCreatingSession || aiChat.isStreaming, + isAILoading: aiIsCreatingSession || aiIsStreaming, onViewAIResponse: handleViewAIResponse, onClickAIMarker: handleClickAIMarker, aiHistoryForSelection, @@ -1422,7 +1457,7 @@ const ReviewApp: React.FC = () => { handleToggleViewed, stagedFiles, stagingFile, stageFile, canStageFiles, stageError, isSearchPending, debouncedSearchQuery, activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, - aiAvailable, aiChat.messages, aiChat.isCreatingSession, aiChat.isStreaming, + aiAvailable, aiMessages, aiIsCreatingSession, aiIsStreaming, handleAskAI, handleViewAIResponse, handleClickAIMarker, aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, @@ -2078,7 +2113,7 @@ const ReviewApp: React.FC = () => { title="AI Chat" > - {aiChat.messages.length > 0 && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai') && ( + {aiMessages.length > 0 && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai') && ( )} @@ -2254,19 +2289,19 @@ const ReviewApp: React.FC = () => { onDeleteEditorAnnotation={deleteEditorAnnotation} prMetadata={prMetadata} aiAvailable={aiAvailable} - aiMessages={aiChat.messages} - isAICreatingSession={aiChat.isCreatingSession} - isAIStreaming={aiChat.isStreaming} + aiMessages={aiMessages} + isAICreatingSession={aiIsCreatingSession} + isAIStreaming={aiIsStreaming} onScrollToAILines={handleScrollToAILines} activeFilePath={files[activeFileIndex]?.path} scrollToQuestionId={scrollToQuestionId} onAskGeneral={handleAskGeneral} - aiPermissionRequests={aiChat.permissionRequests} - onRespondToPermission={aiChat.respondToPermission} + aiPermissionRequests={aiPermissionRequests} + onRespondToPermission={respondToAIPermission} aiProviders={aiProviders} aiConfig={aiConfig} onAIConfigChange={handleAIConfigChange} - hasAISession={!!aiChat.sessionId} + hasAISession={!!aiSessionId} agentJobs={agentJobs.jobs} agentCapabilities={agentJobs.capabilities} onAgentLaunch={agentJobs.launchJob} @@ -2400,16 +2435,6 @@ const ReviewApp: React.FC = () => { showCancel /> - {/* AI setup dialog — first-run only */} - { - setShowAISetup(false); - handleAIConfigChange({ providerId }); - }} - /> - {/* Diff type setup dialog — first-run only */} {showDiffTypeSetup && ( = ({ ); } - const effectiveProviderId = selectedProviderId ?? providers[0]?.id; - const currentProvider = providers.find(p => p.id === effectiveProviderId) ?? providers[0]; + const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0]; if (!currentProvider) return null; + const effectiveProviderId = currentProvider.id; const meta = getProviderMeta(currentProvider.name); const Icon = meta.icon; @@ -233,13 +215,13 @@ export const AIConfigBar: React.FC = ({ className="flex items-center gap-1 px-1 py-0.5 -mx-1 rounded hover:bg-muted/50 transition-colors" title="Reasoning effort" > - {REASONING_EFFORTS.find(e => e.id === (selectedReasoningEffort ?? 'high'))?.label ?? 'High'} + {AI_REASONING_EFFORTS.find(e => e.id === (selectedReasoningEffort ?? 'high'))?.label ?? 'High'} {chevron} {openMenu === 'effort' && (
- {REASONING_EFFORTS.map(e => { + {AI_REASONING_EFFORTS.map(e => { const isActive = e.id === (selectedReasoningEffort ?? 'high'); 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..2e8393c3e --- /dev/null +++ b/packages/ui/components/ai/AIProviderBar.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { getProviderMeta } from '../ProviderIcons'; +import { AI_REASONING_EFFORTS, type AIProviderOption } from '../../utils/aiProvider'; + +interface AIProviderBarProps { + providers: AIProviderOption[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort?: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange?: (effort: string | null) => void; +} + +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..c988eb1c2 --- /dev/null +++ b/packages/ui/components/ai/DocumentAIChatPanel.tsx @@ -0,0 +1,287 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat'; +import type { AIProviderOption } from '../../utils/aiProvider'; +import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat'; +import { OverlayScrollArea } from '../OverlayScrollArea'; +import { SparklesIcon } from '../SparklesIcon'; +import { AIProviderBar } from './AIProviderBar'; +import { submitHint } from '../../utils/platform'; + +interface DocumentAIChatPanelProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + onAskGeneral?: (question: string) => void; + permissionRequests?: PendingPermission[]; + onRespondToPermission?: (requestId: string, allow: boolean) => void; + aiProviders?: AIProviderOption[]; + aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; + onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; +} + +function truncate(text: string, max = 180): string { + if (text.length <= max) return text; + return `${text.slice(0, max).trimEnd()}...`; +} + +function formatToolInput(toolName: string, input: Record): string | null { + if (!input || Object.keys(input).length === 0) return null; + + if (toolName === 'Bash' && typeof input.command === 'string') { + return input.command; + } + if ((toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') && typeof input.file_path === 'string') { + return input.file_path; + } + if (toolName === 'Glob' && typeof input.pattern === 'string') { + return input.pattern; + } + if (toolName === 'Grep' && typeof input.pattern === 'string') { + const path = typeof input.path === 'string' ? ` in ${input.path}` : ''; + return `${input.pattern}${path}`; + } + if ((toolName === 'WebFetch' || toolName === 'WebSearch') && typeof input.url === 'string') { + return input.url; + } + + try { + return truncate(JSON.stringify(input), 240); + } catch { + return String(input); + } +} + +export const DocumentAIChatPanel: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, +}) => { + const scrollRef = useRef(null); + const [generalInput, setGeneralInput] = useState(''); + const latestMessage = messages[messages.length - 1]; + const latestResponseText = latestMessage?.response.text ?? ''; + + useEffect(() => { + if (!scrollRef.current) return; + const last = scrollRef.current.querySelector('[data-ai-message]:last-child'); + last?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [messages.length, latestResponseText]); + + const handleGeneralSubmit = useCallback(() => { + const question = generalInput.trim(); + if (!question || !onAskGeneral) return; + onAskGeneral(question); + setGeneralInput(''); + }, [generalInput, onAskGeneral]); + + return ( +
+ +
+ {messages.length === 0 && !isCreatingSession && ( +
+
+ +
+

+ {onAskGeneral ? ( + <>Select text and click Ask AI, or ask a general question below. + ) : ( + <>Select text and click Ask AI. + )} +

+
+ )} + + {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 }) => { + const label = permission.title || permission.displayName || permission.toolName; + const toolInput = formatToolInput(permission.toolName, permission.toolInput); + + return ( +
+

+ Permission Request +

+

+ {label} +

+ {toolInput && ( +

+ {toolInput} +

+ )} + {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 ( +
+
+