diff --git a/apps/hook/server/clearContextSetting.test.ts b/apps/hook/server/clearContextSetting.test.ts new file mode 100644 index 000000000..6e427893b --- /dev/null +++ b/apps/hook/server/clearContextSetting.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +let tmpHome: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "plannotator-clear-context-test-")); + mock.module("os", () => { + const realOs = require("node:os"); + return { ...realOs, homedir: () => tmpHome }; + }); +}); + +afterEach(() => { + mock.restore(); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +async function freshImport() { + return (await import( + `./clearContextSetting?t=${Date.now()}-${Math.random()}` + )) as typeof import("./clearContextSetting"); +} + +function writeConsent() { + mkdirSync(join(tmpHome, ".plannotator", "consent"), { recursive: true }); + writeFileSync( + join(tmpHome, ".plannotator", "consent", "clear-context-setting.json"), + JSON.stringify({ consented: true }), + "utf8", + ); +} + +describe("clearContextSetting", () => { + test("does not create settings without consent", async () => { + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + expect(existsSync(join(tmpHome, ".claude", "settings.json"))).toBe(false); + }); + + test("creates settings with showClearContextOnPlanAccept when consent exists", async () => { + writeConsent(); + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("preserves existing settings keys", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ theme: "dark", env: { A: "B" } }), + "utf8", + ); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.theme).toBe("dark"); + expect(settings.env).toEqual({ A: "B" }); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("is idempotent when setting is already enabled", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ showClearContextOnPlanAccept: true }), + "utf8", + ); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("leaves malformed settings JSON untouched", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + const malformed = "{ this is not valid json"; + writeFileSync(join(tmpHome, ".claude", "settings.json"), malformed, "utf8"); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + expect(readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8")).toBe( + malformed, + ); + }); + + test("records consent atomically", async () => { + const { recordConsent } = await freshImport(); + recordConsent(); + + const consentPath = join( + tmpHome, + ".plannotator", + "consent", + "clear-context-setting.json", + ); + expect(existsSync(consentPath)).toBe(true); + const consent = JSON.parse(readFileSync(consentPath, "utf8")); + expect(consent.consented).toBe(true); + expect(typeof consent.recordedAt).toBe("string"); + }); + + test("reports disabled when settings are missing", async () => { + const { isClearContextSettingEnabled } = await freshImport(); + expect(isClearContextSettingEnabled()).toBe(false); + }); + + test("reports enabled when setting is true", async () => { + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ showClearContextOnPlanAccept: true }), + "utf8", + ); + + const { isClearContextSettingEnabled } = await freshImport(); + expect(isClearContextSettingEnabled()).toBe(true); + }); +}); diff --git a/apps/hook/server/clearContextSetting.ts b/apps/hook/server/clearContextSetting.ts new file mode 100644 index 000000000..7cb1f62f2 --- /dev/null +++ b/apps/hook/server/clearContextSetting.ts @@ -0,0 +1,105 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "fs"; +import { randomBytes } from "crypto"; +import { homedir } from "os"; +import { dirname, join } from "path"; + +const SETTING_KEY = "showClearContextOnPlanAccept"; + +function consentPath(): string { + return join( + homedir(), + ".plannotator", + "consent", + "clear-context-setting.json", + ); +} + +function settingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +function hasConsent(): boolean { + try { + if (!existsSync(consentPath())) return false; + const data = JSON.parse(readFileSync(consentPath(), "utf8")); + return data?.consented === true; + } catch { + return false; + } +} + +function writeJsonAtomic(path: string, data: Record): void { + const tmp = join( + dirname(path), + `plannotator-settings-${randomBytes(4).toString("hex")}.json`, + ); + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export function recordConsent(): void { + const dir = join(homedir(), ".plannotator", "consent"); + mkdirSync(dir, { recursive: true }); + writeJsonAtomic(consentPath(), { + consented: true, + recordedAt: new Date().toISOString(), + }); +} + +export function isClearContextSettingEnabled(): boolean { + try { + if (!existsSync(settingsPath())) return false; + const settings = JSON.parse(readFileSync(settingsPath(), "utf8")); + return settings?.[SETTING_KEY] === true; + } catch { + return false; + } +} + +export async function ensureClearContextSettingEnabled(): Promise { + if (!hasConsent()) { + console.error( + "[plannotator] clearContextSetting: no consent recorded; skipping settings mutation", + ); + return isClearContextSettingEnabled(); + } + + let settings: Record; + try { + settings = existsSync(settingsPath()) + ? JSON.parse(readFileSync(settingsPath(), "utf8")) + : {}; + } catch (error: any) { + console.error( + `[plannotator] clearContextSetting: malformed settings JSON; skipping mutation: ${error?.message}`, + ); + return false; + } + + if (settings[SETTING_KEY] === true) return true; + + settings[SETTING_KEY] = true; + mkdirSync(join(homedir(), ".claude"), { recursive: true }); + + try { + writeJsonAtomic(settingsPath(), settings); + } catch (error: any) { + try { + await Bun.sleep(50); + writeJsonAtomic(settingsPath(), settings); + } catch (retryError: any) { + console.error( + `[plannotator] clearContextSetting: write failed after retry; skipping: ${retryError?.message}`, + ); + return false; + } + } + + return true; +} diff --git a/apps/hook/server/hookDecision.test.ts b/apps/hook/server/hookDecision.test.ts new file mode 100644 index 000000000..63e678c76 --- /dev/null +++ b/apps/hook/server/hookDecision.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { formatClaudePlanHookOutput } from "./hookDecision"; + +describe("formatClaudePlanHookOutput", () => { + test("native handoff emits PreToolUse ask only when native clear was enabled", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", deferToNativeForClear: true }, + hookEventName: "PreToolUse", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + })).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }); + }); + + test("PermissionRequest native defer falls back to explicit allow JSON", () => { + const output = formatClaudePlanHookOutput({ + result: { approved: true, deferToNativeForClear: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + }) as any; + + expect(output.hookSpecificOutput.hookEventName).toBe("PermissionRequest"); + expect(output.hookSpecificOutput.decision.behavior).toBe("allow"); + expect(output.hookSpecificOutput.decision.updatedPermissions).toEqual([ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ]); + expect(output.systemMessage).toContain("/clear"); + }); + + test("normal PermissionRequest approval includes updatedPermissions", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", clearContextNudge: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + })).toEqual({ + systemMessage: expect.stringContaining("/clear"), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + updatedPermissions: [ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ], + }, + }, + }); + }); +}); diff --git a/apps/hook/server/hookDecision.ts b/apps/hook/server/hookDecision.ts new file mode 100644 index 000000000..1f613f1cf --- /dev/null +++ b/apps/hook/server/hookDecision.ts @@ -0,0 +1,110 @@ +import { + buildPlanFileRule, + getPlanDeniedPrompt, + getPlanToolName, +} from "@plannotator/shared/prompts"; +import type { Origin } from "@plannotator/shared/agents"; + +export type PlanDecisionResult = { + approved: boolean; + feedback?: string; + permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +}; + +export type ClaudeHookEventName = "PreToolUse" | "PermissionRequest"; + +export function normalizeClaudeHookEventName(value: unknown): ClaudeHookEventName { + return value === "PreToolUse" ? "PreToolUse" : "PermissionRequest"; +} + +function clearContextSystemMessage(): string { + return "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session."; +} + +export function formatClaudePlanHookOutput(options: { + result: PlanDecisionResult; + hookEventName: ClaudeHookEventName; + toolName: string; + detectedOrigin: Origin; + nativeClearEnabled?: boolean; + planFilename?: string; +}): Record { + const { result, hookEventName, toolName, detectedOrigin, nativeClearEnabled, planFilename } = options; + const isExitPlanMode = toolName === "ExitPlanMode"; + + if (result.approved && result.deferToNativeForClear && isExitPlanMode) { + if (hookEventName === "PreToolUse" && nativeClearEnabled) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }; + } + + result.clearContextNudge = true; + result.permissionMode ||= "bypassPermissions"; + } + + if (hookEventName === "PreToolUse") { + if (result.approved) { + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: buildPlanFileRule(getPlanToolName(detectedOrigin), planFilename), + feedback: result.feedback || "Plan changes requested", + }), + }, + }; + } + + if (result.approved) { + const updatedPermissions = []; + if (result.permissionMode) { + updatedPermissions.push({ + type: "setMode", + mode: result.permissionMode, + destination: "session", + }); + } + + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + ...(updatedPermissions.length > 0 && { updatedPermissions }), + }, + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: "", + feedback: result.feedback || "Plan changes requested", + }), + }, + }, + }; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index f9f82e4bc..dc968ce3b 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -67,10 +67,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; +import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -83,23 +80,59 @@ import { startGoalSetupServer, handleGoalSetupServerReady, } from "@plannotator/server/goal-setup"; -import { type DiffType, detectManagedVcs, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs"; -import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; +import { + type DiffType, + detectManagedVcs, + prepareLocalReviewDiff, + gitRuntime, +} from "@plannotator/server/vcs"; +import { + loadConfig, + resolveDefaultDiffType, + resolveUseJina, +} from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; import { normalizeGoalSetupBundle, type GoalSetupStage, } from "@plannotator/shared/goal-setup"; -import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; +import { + stripAtPrefix, + resolveAtReference, +} from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; -import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { + urlToMarkdown, + isConvertedSource, +} from "@plannotator/shared/url-to-markdown"; +import { + fetchRef, + createWorktree, + removeWorktree, + ensureObjectAvailable, +} from "@plannotator/shared/worktree"; +import { + createWorktreePool, + type WorktreePool, +} from "@plannotator/shared/worktree-pool"; +import { + parsePRUrl, + checkPRAuth, + fetchPR, + getCliName, + getCliInstallUrl, + getMRLabel, + getMRNumberLabel, + getDisplayRepo, +} from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { + resolveMarkdownFile, + resolveUserPath, + hasMarkdownFiles, +} from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, readdirSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -108,7 +141,11 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; +import { + registerSession, + unregisterSession, + listSessions, +} from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -126,8 +163,16 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session"; +import { + findCodexRolloutByThreadId, + getLatestCodexPlan, + getRecentCodexMessages, +} from "./codex-session"; +import { + findCopilotPlanContent, + findCopilotSessionForCwd, + getRecentCopilotMessages, +} from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -136,9 +181,22 @@ import { isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; +import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { + formatClaudePlanHookOutput, + normalizeClaudeHookEventName, +} from "./hookDecision"; +import { + logInjectorDecision, + shouldFireInjector, + spawnKeystrokeInjector, +} from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; -import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace"; +import { + buildLocalWorkspaceReview, + type WorkspaceDiffType, +} from "@plannotator/server/review-workspace"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text @@ -210,7 +268,9 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log(JSON.stringify({ decision: "block", reason: result.feedback })); + console.log( + JSON.stringify({ decision: "block", reason: result.feedback }), + ); } return; } @@ -220,7 +280,12 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); + console.log( + JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + }), + ); } return; } @@ -232,10 +297,7 @@ function emitAnnotateOutcome(result: { if (result.feedback) console.log(result.feedback); } -async function loadGoalSetupBundle( - stage: GoalSetupStage, - bundlePath: string -) { +async function loadGoalSetupBundle(stage: GoalSetupStage, bundlePath: string) { const raw = bundlePath === "-" ? await Bun.stdin.text() @@ -285,12 +347,17 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - (originOverride && originOverride in AGENT_CONFIG) ? originOverride : - process.env.CODEX_THREAD_ID ? "codex" : - process.env.COPILOT_CLI ? "copilot-cli" : - process.env.OPENCODE ? "opencode" : - process.env.GEMINI_CLI ? "gemini-cli" : - "claude-code"; + originOverride && originOverride in AGENT_CONFIG + ? originOverride + : process.env.CODEX_THREAD_ID + ? "codex" + : process.env.COPILOT_CLI + ? "copilot-cli" + : process.env.OPENCODE + ? "opencode" + : process.env.GEMINI_CLI + ? "gemini-cli" + : "claude-code"; type OpenCodeBridgeAgent = { name: string; @@ -313,24 +380,34 @@ function parseOpenCodeBridgeInput( try { return JSON.parse(inputJson) as T & OpenCodeBridgeInput; } catch (error) { - console.error(`Failed to parse ${mode} input: ${error instanceof Error ? error.message : String(error)}`); + console.error( + `Failed to parse ${mode} input: ${error instanceof Error ? error.message : String(error)}`, + ); process.exit(1); } } function getBridgeSharingEnabled(input: OpenCodeBridgeInput): boolean { - return typeof input.sharingEnabled === "boolean" ? input.sharingEnabled : sharingEnabled; + return typeof input.sharingEnabled === "boolean" + ? input.sharingEnabled + : sharingEnabled; } function getBridgeShareBaseUrl(input: OpenCodeBridgeInput): string | undefined { - return typeof input.shareBaseUrl === "string" && input.shareBaseUrl ? input.shareBaseUrl : shareBaseUrl; + return typeof input.shareBaseUrl === "string" && input.shareBaseUrl + ? input.shareBaseUrl + : shareBaseUrl; } function getBridgePasteApiUrl(input: OpenCodeBridgeInput): string | undefined { - return typeof input.pasteApiUrl === "string" && input.pasteApiUrl ? input.pasteApiUrl : pasteApiUrl; + return typeof input.pasteApiUrl === "string" && input.pasteApiUrl + ? input.pasteApiUrl + : pasteApiUrl; } -function normalizeOpenCodeBridgeAgents(value: unknown): OpenCodeBridgeAgent[] | undefined { +function normalizeOpenCodeBridgeAgents( + value: unknown, +): OpenCodeBridgeAgent[] | undefined { if (!Array.isArray(value)) return undefined; const agents = value @@ -340,7 +417,9 @@ function normalizeOpenCodeBridgeAgents(value: unknown): OpenCodeBridgeAgent[] | if (typeof record.name !== "string" || !record.name) return null; return { name: record.name, - ...(typeof record.description === "string" && { description: record.description }), + ...(typeof record.description === "string" && { + description: record.description, + }), mode: typeof record.mode === "string" ? record.mode : "primary", ...(typeof record.hidden === "boolean" && { hidden: record.hidden }), }; @@ -376,12 +455,16 @@ function emitOpenCodeAnnotateOutcome(result: { console.log(JSON.stringify({ decision: "dismissed" })); return; } - console.log(JSON.stringify({ - decision: "annotated", - feedback: result.feedback || "", - ...(result.selectedMessageId && { selectedMessageId: result.selectedMessageId }), - ...(result.feedbackScope && { feedbackScope: result.feedbackScope }), - })); + console.log( + JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + ...(result.selectedMessageId && { + selectedMessageId: result.selectedMessageId, + }), + ...(result.feedbackScope && { feedbackScope: result.feedbackScope }), + }), + ); } if (args[0] === "sessions") { @@ -392,7 +475,9 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + console.error( + `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, + ); process.exit(0); } @@ -410,7 +495,9 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error(`Session #${n} not found. ${sessions.length} active session(s).`); + console.error( + `Session #${n} not found. ${sessions.length} active session(s).`, + ); process.exit(1); } await openBrowser(session.url); @@ -422,13 +509,17 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); - const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); + const age = Math.round( + (Date.now() - new Date(s.startedAt).getTime()) / 60000, + ); + const ageStr = + age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error( + ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, + ); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); - } else if (args[0] === "setup-goal") { // ============================================ // GOAL SETUP MODE @@ -439,7 +530,7 @@ if (args[0] === "sessions") { if ((stage !== "interview" && stage !== "facts") || !bundlePath) { console.error( - "Usage: plannotator setup-goal [--json]" + "Usage: plannotator setup-goal [--json]", ); process.exit(1); } @@ -449,7 +540,7 @@ if (args[0] === "sessions") { bundle = await loadGoalSetupBundle(stage, bundlePath); } catch (err) { console.error( - `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}` + `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } @@ -487,10 +578,11 @@ if (args[0] === "sessions") { stage: result.result.stage, result: result.result, }; - console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2)); + console.log( + jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2), + ); } process.exit(0); - } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -504,13 +596,17 @@ if (args[0] === "sessions") { let rawPatch: string; let gitRef: string; let diffError: string | undefined; - let gitContext: Awaited>["gitContext"] | undefined; + let gitContext: + | Awaited>["gitContext"] + | undefined; let prMetadata: Awaited>["metadata"] | undefined; let initialDiffType: DiffType | WorkspaceDiffType | undefined; let agentCwd: string | undefined; let worktreePool: WorktreePool | undefined; let worktreeCleanup: (() => void | Promise) | undefined; - let workspace: Awaited> | undefined; + let workspace: + | Awaited> + | undefined; if (isPRMode) { // --- PR Review Mode --- @@ -519,7 +615,9 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); + console.error( + " GitLab: https://gitlab.com/group/project/-/merge_requests/42", + ); process.exit(1); } @@ -531,7 +629,9 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); + console.error( + `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, + ); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -539,7 +639,9 @@ if (args[0] === "sessions") { process.exit(1); } - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + console.error( + `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, + ); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -557,42 +659,68 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = + prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); - const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join( + realpathSync(tmpdir()), + `plannotator-pr-${identifier}-${suffix}`, + ); + const prNumber = + prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = + prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if ( + prMetadata.baseBranch.includes("..") || + prMetadata.baseBranch.startsWith("-") + ) + throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) + throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); + const remoteResult = await gitRuntime.runGit([ + "remote", + "get-url", + "origin", + ]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = + !!currentRepo && + currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); + const httpsHost = (() => { + try { + return new URL(remoteUrl).hostname; + } catch { + return null; + } + })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { /* not in a git repo — cross-repo path */ } + } catch { + /* not in a git repo — cross-repo path */ + } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -602,7 +730,9 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { + cwd: repoDir, + }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -615,41 +745,65 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); + Bun.spawnSync( + ["git", "worktree", "remove", "--force", entry.path], + { cwd: repoDir }, + ); } } catch {} - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) + throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost ? undefined : { - ...process.env, - ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost + ? undefined + : { + ...process.env, + ...(prMetadata.platform === "github" + ? { GH_HOST: host } + : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + [ + cli, + "repo", + "clone", + prRepo, + localPath, + "--", + "--depth=1", + "--no-checkout", + ], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); + throw new Error( + `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, + ); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -658,23 +812,54 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); + if (fetchResult.exitCode !== 0) + throw new Error( + `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, + ); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); + const checkoutResult = Bun.spawnSync( + ["git", "checkout", "FETCH_HEAD"], + { cwd: localPath, stderr: "pipe" }, + ); if (checkoutResult.exitCode !== 0) { - throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); + throw new Error( + `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, + ); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); - Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + const baseFetch = Bun.spawnSync( + ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + if (baseFetch.exitCode !== 0) + console.error( + "Warning: failed to fetch baseSha, agent diffs may be inaccurate", + ); + Bun.spawnSync( + ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + Bun.spawnSync( + [ + "git", + "update-ref", + `refs/remotes/origin/${prMetadata.baseBranch}`, + prMetadata.baseSha, + ], + { cwd: localPath, stderr: "pipe" }, + ); - worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; + worktreeCleanup = () => { + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} + }; process.once("exit", () => { - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } @@ -685,14 +870,22 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + { + path: localPath, + prUrl: prMetadata.url, + number: prNumber, + ready: true, + }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + if (sessionDir) + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -701,7 +894,10 @@ if (args[0] === "sessions") { } else { // --- Local Review Mode --- const config = loadConfig(); - const managedVcs = await detectManagedVcs(process.cwd(), reviewArgs.vcsType); + const managedVcs = await detectManagedVcs( + process.cwd(), + reviewArgs.vcsType, + ); const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto"; if (managedVcs || forcedVcs) { @@ -721,7 +917,9 @@ if (args[0] === "sessions") { hideWhitespace: config.diffOptions?.hideWhitespace ?? false, }); if (workspace.repos.length === 0) { - console.error("Not in a VCS repo and no nested Git/JJ repositories were found."); + console.error( + "Not in a VCS repo and no nested Git/JJ repositories were found.", + ); process.exit(1); } rawPatch = workspace.rawPatch; @@ -740,7 +938,11 @@ if (args[0] === "sessions") { gitRef, error: diffError, origin: detectedOrigin, - diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined, + diffType: workspace + ? (initialDiffType ?? workspace.diffType) + : gitContext + ? (initialDiffType ?? "unstaged") + : undefined, gitContext, prMetadata, workspace, @@ -754,7 +956,12 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); + await writeRemoteShareLink( + rawPatch, + shareBaseUrl, + "review changes", + "diff only", + ).catch(() => {}); } }, }); @@ -766,7 +973,9 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, + label: isPRMode + ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` + : `review-${reviewProject}`, }); // Wait for user feedback @@ -790,7 +999,6 @@ if (args[0] === "sessions") { } } process.exit(0); - } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -798,7 +1006,9 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); + console.error( + "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", + ); process.exit(1); } @@ -828,31 +1038,46 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); + console.error( + `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, + ); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); + console.error( + `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, + ); } } catch (err) { - console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } - catch { return false; } + try { + return statSync(resolveUserPath(c, projectRoot)).isDirectory(); + } catch { + return false; + } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + if ( + !hasMarkdownFiles( + resolvedArg, + FILE_BROWSER_EXCLUDED, + /\.(mdx?|html?)$/i, + ) + ) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -872,7 +1097,9 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + console.error( + `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, + ); process.exit(1); } const html = await htmlFile.text(); @@ -885,7 +1112,9 @@ if (args[0] === "sessions") { } absolutePath = resolvedArg; sourceInfo = path.basename(resolvedArg); - console.error(`${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`); + console.error( + `${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`, + ); } else { // Single markdown file annotation mode // Strip-first with literal-@ fallback (scoped-package-style names). @@ -895,7 +1124,9 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); + console.error( + `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, + ); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -935,7 +1166,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink( + markdown, + shareBaseUrl, + "annotate", + "document only", + ).catch(() => {}); } }, }); @@ -964,7 +1200,6 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -1003,8 +1238,16 @@ if (args[0] === "sessions") { if (process.env.PLANNOTATOR_DEBUG) { console.error(`[DEBUG] Rollout: ${rolloutPath}`); } - recentMessages = getRecentCodexMessages(rolloutPath, RECENT_MESSAGES_LIMIT, { beforeActiveTurn: true }) - .map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp })); + recentMessages = getRecentCodexMessages( + rolloutPath, + RECENT_MESSAGES_LIMIT, + { beforeActiveTurn: true }, + ).map((m) => ({ + messageId: m.messageId, + text: m.text, + lineNumbers: [], + timestamp: m.timestamp, + })); lastMessage = recentMessages[0] ?? null; } } else if (isDroid) { @@ -1019,14 +1262,19 @@ if (args[0] === "sessions") { } const cwdLogs = findDroidSessionLogsForCwd(projectRoot); - const ancestorLogs = cwdLogs.length === 0 - ? findDroidSessionLogsByAncestorWalk(projectRoot) - : []; + const ancestorLogs = + cwdLogs.length === 0 + ? findDroidSessionLogsByAncestorWalk(projectRoot) + : []; if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Droid CWD session logs (mtime): ${cwdLogs.length ? cwdLogs.join(", ") : "(none)"}`); + console.error( + `[DEBUG] Droid CWD session logs (mtime): ${cwdLogs.length ? cwdLogs.join(", ") : "(none)"}`, + ); if (cwdLogs.length === 0) { - console.error(`[DEBUG] Droid ancestor walk: ${ancestorLogs.length ? ancestorLogs.join(", ") : "(none)"}`); + console.error( + `[DEBUG] Droid ancestor walk: ${ancestorLogs.length ? ancestorLogs.join(", ") : "(none)"}`, + ); } } @@ -1035,7 +1283,10 @@ if (args[0] === "sessions") { console.error(`[DEBUG] Droid selected log: ${droidLog ?? "(none)"}`); } if (droidLog) { - recentMessages = getRecentRenderedMessages(droidLog, RECENT_MESSAGES_LIMIT); + recentMessages = getRecentRenderedMessages( + droidLog, + RECENT_MESSAGES_LIMIT, + ); lastMessage = recentMessages[0] ?? null; } } else { @@ -1065,10 +1316,15 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); + console.error( + `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, + ); } for (const logPath of paths) { - const recent = getRecentRenderedMessages(logPath, RECENT_MESSAGES_LIMIT); + const recent = getRecentRenderedMessages( + logPath, + RECENT_MESSAGES_LIMIT, + ); if (recent.length > 0) { recentMessages = recent; lastMessage = recent[0]; @@ -1079,28 +1335,40 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); + tryLogCandidates("Ancestor PID session metadata", () => + ancestorLog ? [ancestorLog] : [], + ); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); + tryLogCandidates("Cwd-scan session metadata", () => + cwdScanLog ? [cwdScanLog] : [], + ); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); + tryLogCandidates("CWD slug match (mtime)", () => + findSessionLogsForCwd(projectRoot), + ); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + tryLogCandidates("Directory ancestor walk", () => + findSessionLogsByAncestorWalk(projectRoot), + ); } if (!lastMessage) { - console.error(stdinFlag - ? "No message content received on stdin." - : "No rendered assistant message found in session logs."); + console.error( + stdinFlag + ? "No message content received on stdin." + : "No rendered assistant message found in session logs.", + ); process.exit(1); } if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); + console.error( + `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, + ); } const annotatedMessage = lastMessage; @@ -1108,9 +1376,14 @@ if (args[0] === "sessions") { // Only ship the picker list when there's a choice to make. The client uses // its presence (length > 1) as the signal to render the picker UI. - const pickerMessages = recentMessages.length > 1 - ? recentMessages.map((m) => ({ messageId: m.messageId, text: m.text, timestamp: m.timestamp })) - : undefined; + const pickerMessages = + recentMessages.length > 1 + ? recentMessages.map((m) => ({ + messageId: m.messageId, + text: m.text, + timestamp: m.timestamp, + })) + : undefined; const server = await startAnnotateServer({ markdown: annotatedMessage.text, @@ -1127,7 +1400,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(annotatedMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + annotatedMessage.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -1150,7 +1428,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -1185,7 +1462,6 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); - } else if (args[0] === "opencode-plan") { // ============================================ // OPENCODE PLUGIN PLAN REVIEW MODE @@ -1195,10 +1471,10 @@ if (args[0] === "sessions") { // that cannot import Bun-only server modules directly. const inputJson = await Bun.stdin.text(); - const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown }>( - "opencode-plan", - inputJson, - ); + const input = parseOpenCodeBridgeInput<{ + plan?: unknown; + timeoutSeconds?: unknown; + }>("opencode-plan", inputJson); const planContent = typeof input.plan === "string" ? input.plan : ""; if (!planContent.trim()) { @@ -1206,11 +1482,14 @@ if (args[0] === "sessions") { process.exit(1); } - const timeoutSeconds = input.timeoutSeconds === null - ? null - : typeof input.timeoutSeconds === "number" && Number.isFinite(input.timeoutSeconds) && input.timeoutSeconds > 0 - ? input.timeoutSeconds - : null; + const timeoutSeconds = + input.timeoutSeconds === null + ? null + : typeof input.timeoutSeconds === "number" && + Number.isFinite(input.timeoutSeconds) && + input.timeoutSeconds > 0 + ? input.timeoutSeconds + : null; const planProject = (await detectProjectName()) ?? "_unknown"; const bridgeSharingEnabled = getBridgeSharingEnabled(input); @@ -1228,7 +1507,12 @@ if (args[0] === "sessions") { await handleServerReady(url, isRemote, port); if (isRemote && bridgeSharingEnabled) { - await writeRemoteShareLink(planContent, bridgeShareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + bridgeShareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1243,35 +1527,39 @@ if (args[0] === "sessions") { label: `plan-${planProject}`, }); - const result = timeoutSeconds === null - ? await server.waitForDecision() - : await new Promise>>((resolve) => { - const timeoutId = setTimeout( - () => - resolve({ - approved: false, - feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`, - }), - timeoutSeconds * 1000, + const result = + timeoutSeconds === null + ? await server.waitForDecision() + : await new Promise>>( + (resolve) => { + const timeoutId = setTimeout( + () => + resolve({ + approved: false, + feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`, + }), + timeoutSeconds * 1000, + ); + + server.waitForDecision().then((decision) => { + clearTimeout(timeoutId); + resolve(decision); + }); + }, ); - server.waitForDecision().then((decision) => { - clearTimeout(timeoutId); - resolve(decision); - }); - }); - await Bun.sleep(1500); server.stop(); - console.log(JSON.stringify({ - approved: result.approved, - ...(result.feedback && { feedback: result.feedback }), - ...(result.savedPath && { savedPath: result.savedPath }), - ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), - })); + console.log( + JSON.stringify({ + approved: result.approved, + ...(result.feedback && { feedback: result.feedback }), + ...(result.savedPath && { savedPath: result.savedPath }), + ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), + }), + ); process.exit(0); - } else if (args[0] === "opencode-review") { // ============================================ // OPENCODE PLUGIN CODE REVIEW MODE @@ -1285,7 +1573,9 @@ if (args[0] === "sessions") { "opencode-review", inputJson, ); - const reviewArgs = parseReviewArgs(typeof input.arguments === "string" ? input.arguments : ""); + const reviewArgs = parseReviewArgs( + typeof input.arguments === "string" ? input.arguments : "", + ); const urlArg = reviewArgs.prUrl; const isPRMode = urlArg !== undefined; @@ -1293,9 +1583,13 @@ if (args[0] === "sessions") { let gitRef: string; let diffError: string | undefined; let userDiffType: DiffType | WorkspaceDiffType | undefined; - let gitContext: Awaited>["gitContext"] | undefined; + let gitContext: + | Awaited>["gitContext"] + | undefined; let prMetadata: Awaited>["metadata"] | undefined; - let workspace: Awaited> | undefined; + let workspace: + | Awaited> + | undefined; let agentCwd: string | undefined; if (isPRMode) { @@ -1305,13 +1599,17 @@ if (args[0] === "sessions") { process.exit(1); } - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + console.error( + `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, + ); try { await checkPRAuth(prRef); } catch (err) { const cliName = getCliName(prRef); - console.error(err instanceof Error ? err.message : `${cliName} auth check failed`); + console.error( + err instanceof Error ? err.message : `${cliName} auth check failed`, + ); process.exit(1); } @@ -1321,7 +1619,11 @@ if (args[0] === "sessions") { gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; prMetadata = pr.metadata; } catch (err) { - console.error(err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`); + console.error( + err instanceof Error + ? err.message + : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`, + ); process.exit(1); } } else { @@ -1350,7 +1652,9 @@ if (args[0] === "sessions") { hideWhitespace: config.diffOptions?.hideWhitespace ?? false, }); if (workspace.repos.length === 0) { - console.error("Not in a VCS repo and no nested Git/JJ repositories were found."); + console.error( + "Not in a VCS repo and no nested Git/JJ repositories were found.", + ); process.exit(1); } rawPatch = workspace.rawPatch; @@ -1391,28 +1695,30 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode && prMetadata - ? `${getMRLabel(prMetadata).toLowerCase()}-review-${getDisplayRepo(prMetadata)}${getMRNumberLabel(prMetadata)}` - : `review-${reviewProject}`, + label: + isPRMode && prMetadata + ? `${getMRLabel(prMetadata).toLowerCase()}-review-${getDisplayRepo(prMetadata)}${getMRNumberLabel(prMetadata)}` + : `review-${reviewProject}`, }); const result = await server.waitForDecision(); await Bun.sleep(1500); server.stop(); - console.log(JSON.stringify({ - decision: result.exit - ? "dismissed" - : result.approved - ? "approved" - : "annotated", - approved: result.approved, - isPRMode, - ...(result.feedback && { feedback: result.feedback }), - ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), - })); + console.log( + JSON.stringify({ + decision: result.exit + ? "dismissed" + : result.approved + ? "approved" + : "annotated", + approved: result.approved, + isPRMode, + ...(result.feedback && { feedback: result.feedback }), + ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), + }), + ); process.exit(0); - } else if (args[0] === "opencode-annotate-last") { // ============================================ // OPENCODE PLUGIN ANNOTATE LAST MESSAGE MODE @@ -1426,24 +1732,42 @@ if (args[0] === "sessions") { const recentMessages = Array.isArray(input.recentMessages) ? input.recentMessages - .map((message): { messageId: string; text: string; timestamp?: string } | null => { - if (!message || typeof message !== "object") return null; - const record = message as Record; - if (typeof record.text !== "string" || !record.text.trim()) return null; - return { - messageId: typeof record.messageId === "string" && record.messageId - ? record.messageId - : crypto.randomUUID(), - text: record.text, - ...(typeof record.timestamp === "string" && { timestamp: record.timestamp }), - }; - }) - .filter((message): message is { messageId: string; text: string; timestamp?: string } => message !== null) + .map( + ( + message, + ): { messageId: string; text: string; timestamp?: string } | null => { + if (!message || typeof message !== "object") return null; + const record = message as Record; + if (typeof record.text !== "string" || !record.text.trim()) + return null; + return { + messageId: + typeof record.messageId === "string" && record.messageId + ? record.messageId + : crypto.randomUUID(), + text: record.text, + ...(typeof record.timestamp === "string" && { + timestamp: record.timestamp, + }), + }; + }, + ) + .filter( + ( + message, + ): message is { + messageId: string; + text: string; + timestamp?: string; + } => message !== null, + ) : []; const lastMessage = recentMessages[0] ?? null; if (!lastMessage) { - console.error("No assistant message found in opencode-annotate-last input."); + console.error( + "No assistant message found in opencode-annotate-last input.", + ); process.exit(1); } @@ -1487,7 +1811,6 @@ if (args[0] === "sessions") { emitOpenCodeAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -1498,7 +1821,13 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; + let event: { + toolName: string; + toolArgs: string; + cwd: string; + timestamp: number; + sessionId?: string; + }; try { event = JSON.parse(eventJson); @@ -1533,7 +1862,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1554,23 +1888,26 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log(JSON.stringify({ - permissionDecision: "allow", - })); + console.log( + JSON.stringify({ + permissionDecision: "allow", + }), + ); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log(JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - })); + console.log( + JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + }), + ); } process.exit(0); - } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1579,7 +1916,9 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); + console.error( + `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, + ); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1621,7 +1960,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + msg.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -1642,7 +1986,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1665,20 +2008,64 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - })); + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + }), + ); process.exit(0); - } else { // ============================================ // PLAN REVIEW MODE (default) // ============================================ + /** + * Read the most recently modified .md file from CC's plansDirectory. + * + * CC's ExitPlanMode tool has no `plan` parameter — it writes the plan to + * disk and reads it back internally. The PermissionRequest hook therefore + * never receives plan content inline; the only reliable source is the file + * CC wrote. This is the intended path for CC, not a fallback shim. + */ + async function readLatestCCPlanFile(): Promise { + try { + const settingsPath = path.join( + process.env.HOME ?? "", + ".claude", + "settings.json", + ); + if (!existsSync(settingsPath)) return ""; + const settings: Record = JSON.parse( + await Bun.file(settingsPath).text(), + ); + const plansDir: string = settings.plansDirectory ?? "claude-code-plans"; + const resolved = path.isAbsolute(plansDir) + ? plansDir + : path.join(process.cwd(), plansDir); + if (!existsSync(resolved)) return ""; + const newest = readdirSync(resolved) + .filter((f) => f.endsWith(".md")) + .map((f) => { + // Tolerate broken symlinks / races: a single unreadable entry + // must not blank out the whole resolution. mtime -1 sorts last. + try { + return { f, mtime: statSync(path.join(resolved, f)).mtimeMs }; + } catch { + return { f, mtime: -1 }; + } + }) + .filter((e) => e.mtime >= 0) + .sort((a, b) => b.mtime - a.mtime)[0]; + return newest ? await Bun.file(path.join(resolved, newest.f)).text() : ""; + } catch { + return ""; + } + } + // Read hook event from stdin const eventJson = await Bun.stdin.text(); if (!eventJson.trim()) { @@ -1725,7 +2112,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + latestPlan.text, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1755,7 +2147,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } @@ -1767,8 +2159,9 @@ if (args[0] === "sessions") { let isGemini = false; let planFilename = ""; - // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + // Detect harness: Gemini sends plan_filename (file on disk), CC reads from plansDirectory + planFilename = + event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1776,13 +2169,28 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); + const planFilePath = path.join( + projectTempDir, + event.session_id, + "plans", + planFilename, + ); planContent = await Bun.file(planFilePath).text(); } else { - planContent = event.tool_input?.plan || ""; + // CC does not inline plan content in the PermissionRequest hook payload — + // ExitPlanMode has no `plan` parameter. Fall back to the most recently + // modified .md file in plansDirectory (relative to cwd, matching CC's + // own resolution of the plansDirectory setting). + planContent = event.tool_input?.plan || (await readLatestCCPlanFile()); } permissionMode = event.permission_mode || "default"; + const toolName: string = + typeof event.tool_name === "string" + ? event.tool_name + : typeof event.toolName === "string" + ? event.toolName + : ""; if (!planContent) { console.error("No plan content in hook event"); @@ -1796,6 +2204,7 @@ if (args[0] === "sessions") { plan: planContent, origin: isGemini ? "gemini-cli" : detectedOrigin, permissionMode, + toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -1804,7 +2213,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1831,21 +2245,50 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); + console.log( + result.feedback + ? JSON.stringify({ systemMessage: result.feedback }) + : "{}", + ); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), + planFileRule: buildPlanFileRule( + getPlanToolName("gemini-cli"), + planFilename, + ), feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } } else { - // Claude Code: PermissionRequest hook decision + const hookEventName = normalizeClaudeHookEventName(event.hook_event_name); + if ( + result.approved && + toolName === "ExitPlanMode" && + (result.deferToNativeForClear || result.permissionMode === "deferNative") + ) { + // Step aside: emit nothing so CC shows its own native plan dialog + // (which offers clear-context + bypass). behavior:"defer" is NOT valid + // here — it belongs on HookPermissionDecision (PreToolUse), not on + // PermissionRequestHookSpecificOutput.decision. + const nativeClearEnabled = await ensureClearContextSettingEnabled(); + if (nativeClearEnabled) { + const fire = shouldFireInjector(result); + logInjectorDecision(result, fire); + if (fire) { + spawnKeystrokeInjector(); + } + process.exit(0); + } + result.clearContextNudge = true; + result.permissionMode = "bypassPermissions"; + } + if (result.approved) { const updatedPermissions = []; if (result.permissionMode) { @@ -1858,6 +2301,10 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ + ...(result.clearContextNudge && { + systemMessage: + "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", + }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { @@ -1865,7 +2312,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }) + }), ); } else { console.log( @@ -1881,7 +2328,7 @@ if (args[0] === "sessions") { }), }, }, - }) + }), ); } } diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts new file mode 100644 index 000000000..66424d617 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -0,0 +1,171 @@ +import { + describe, + it, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; +import { + shouldAutoSelectNativeClear, + shouldFireInjector, + spawnKeystrokeInjector, +} from "./keystrokeInjector"; + +describe("shouldFireInjector", () => { + const noEnv = {} as NodeJS.ProcessEnv; + + it("fires for one-shot deferToNativeForClear", () => { + expect(shouldFireInjector({ deferToNativeForClear: true }, noEnv)).toBe( + true, + ); + }); + + it("fires for persistent permissionMode deferNative", () => { + expect(shouldFireInjector({ permissionMode: "deferNative" }, noEnv)).toBe( + true, + ); + }); + + it("fires when env override is set", () => { + expect( + shouldFireInjector({}, { + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + }); + + it("does not fire without flag, mode, or env", () => { + expect( + shouldFireInjector({ permissionMode: "bypassPermissions" }, noEnv), + ).toBe(false); + expect(shouldFireInjector({}, noEnv)).toBe(false); + }); +}); + +describe("shouldAutoSelectNativeClear", () => { + it("defaults to false", () => { + expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); + +describe("spawnKeystrokeInjector", () => { + let spawnCalls: { cmd: string[]; opts: Record }[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalPlatform: string; + + function setPlatform(v: string) { + Object.defineProperty(process, "platform", { value: v, writable: true }); + } + + beforeEach(() => { + spawnCalls = []; + originalEnv = { ...process.env }; + originalPlatform = process.platform; + delete process.env["TMUX_PANE"]; + + const mockChild = { unref: mock(() => {}) }; + spyOn(Bun, "spawn").mockImplementation( + (cmd: string[], opts: Record) => { + spawnCalls.push({ cmd, opts }); + return mockChild as ReturnType; + }, + ); + }); + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + mock.restore(); + }); + + it("uses tmux send-keys when TMUX_PANE is set", () => { + process.env["TMUX_PANE"] = "%3"; + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].cmd[0]).toBe("bash"); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("tmux send-keys"); + expect(script).toContain("%3"); + expect(script).toContain("1 Enter"); + expect(script).not.toContain("osascript"); + }); + + it("uses osascript on macOS when no tmux pane", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("osascript"); + expect(script).toContain('application "Warp" is running'); + expect(script).toContain("iTerm2"); + expect(script).toContain("Terminal"); + expect(script).toContain('keystroke "1"'); + expect(script).not.toContain("tmux send-keys"); + // Accessibility self-check + expect(script).toContain("UI elements enabled"); + expect(script).toContain("accessibility-not-enabled"); + expect(script).toContain('log "accessibility=true"'); + // Warp timing hardening + expect(script).toContain("delay 0.30"); + expect(script).toContain("delay 0.15"); + // Positive observability markers + expect(script).toContain("start"); + expect(script).toContain('log "injected"'); + }); + + it("no-op on linux without tmux", () => { + setPlatform("linux"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("no-op on windows without tmux", () => { + setPlatform("win32"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("spawn is detached and unreffed (does not block caller)", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(); + + expect(spawnCalls).toHaveLength(1); + const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; + expect(opts.detached).toBe(true); + expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("embeds delay in tmux script via sleep", () => { + process.env["TMUX_PANE"] = "%0"; + spawnKeystrokeInjector(1200); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("sleep 1.20"); + }); + + it("embeds delay in osascript via 'delay' statement", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(800); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("delay 0.80"); + }); +}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts new file mode 100644 index 000000000..d79f90896 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.ts @@ -0,0 +1,112 @@ +// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. +import { appendFileSync, mkdirSync } from "node:fs"; + +const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; + +// osascript stderr is redirected here so an Accessibility/Automation (TCC) +// denial is diagnosable instead of failing silently. +const INJECTOR_LOG_DIR = `${process.env["HOME"] ?? "~"}/.plannotator`; +const INJECTOR_LOG = `${INJECTOR_LOG_DIR}/keystroke-injector.log`; + +export function shouldAutoSelectNativeClear( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; +} + +// Both UI paths into the native-defer branch are explicit user opt-ins: the +// one-shot dialog button sends deferToNativeForClear, the persistent +// permission-mode setting sends permissionMode="deferNative" (configured in +// settings, replayed from storage). Either fires the injector; the env var +// remains as a force-on override for other flows. +export function shouldFireInjector( + result: { deferToNativeForClear?: boolean; permissionMode?: string }, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + result.deferToNativeForClear === true || + result.permissionMode === "deferNative" || + shouldAutoSelectNativeClear(env) + ); +} + +// Records why the defer branch did/didn't inject — an empty log was previously +// ambiguous between "branch not entered" and "gate skipped". +export function logInjectorDecision( + result: { deferToNativeForClear?: boolean; permissionMode?: string }, + fired: boolean, +): void { + try { + mkdirSync(INJECTOR_LOG_DIR, { recursive: true }); + const time = new Date().toISOString(); + appendFileSync( + INJECTOR_LOG, + `${time} defer-branch flag=${result.deferToNativeForClear === true} mode=${result.permissionMode ?? "none"} injector=${fired ? "fired" : "skipped"}\n`, + ); + } catch { + // Logging must never block the approval flow. + } +} + +// Default delay raised from 600ms: CC needs time to render its native dialog +// after exit(0). Firing too early lands "1\n" before the dialog exists. No +// repeat-spray — a stray keystroke after the dialog closes lands in the prompt. +export function spawnKeystrokeInjector(delayMs = 1200): void { + const delaySec = (delayMs / 1000).toFixed(2); + const tmuxPane = process.env["TMUX_PANE"]; + + let script: string | null = null; + + if (tmuxPane) { + script = `mkdir -p ${JSON.stringify(INJECTOR_LOG_DIR)}; echo "$(date +%T) start" >> ${JSON.stringify(INJECTOR_LOG)}; sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter 2>>${JSON.stringify(INJECTOR_LOG)} && echo "$(date +%T) injected" >> ${JSON.stringify(INJECTOR_LOG)}`; + } else if (process.platform === "darwin") { + const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); + // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". + // Check by bundle name first, then fall back to process-name search for other terminals. + script = [ + `mkdir -p ${JSON.stringify(INJECTOR_LOG_DIR)}`, + `echo "$(date +%T) start" >> ${JSON.stringify(INJECTOR_LOG)}`, + `osascript 2>>${JSON.stringify(INJECTOR_LOG)} <<'APPLESCRIPT'`, + `tell application "System Events" to set axEnabled to (UI elements enabled)`, + `if not axEnabled then`, + ` log "accessibility-not-enabled — grant Accessibility in System Settings → Privacy & Security → Accessibility"`, + ` return`, + `end if`, + `log "accessibility=true"`, + `delay ${delaySec}`, + `if application "Warp" is running then`, + ` tell application "Warp" to activate`, + ` delay 0.30`, + ` tell application "System Events"`, + ` keystroke "1"`, + ` delay 0.15`, + ` key code 36`, + ` end tell`, + ` log "injected"`, + `else`, + ` tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.30`, + ` keystroke "1"`, + ` delay 0.15`, + ` key code 36`, + ` log "injected"`, + ` exit repeat`, + ` end if`, + ` end repeat`, + ` end tell`, + `end if`, + `APPLESCRIPT`, + ].join("\n"); + } + + if (!script) return; // Linux/Windows without tmux: user must press 1 manually + + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); +} diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 0b8edf22e..aafde8971 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -74,6 +74,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -249,6 +250,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index cc95c094b..ab96e378f 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { contentHash, deleteDraft } from "../generated/draft.js"; import { @@ -51,12 +53,17 @@ import { } from "./reference.js"; import { warmFileListCache } from "../generated/resolve-file.js"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + export interface PlanReviewDecision { approved: boolean; feedback?: string; savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -240,7 +247,7 @@ export async function startPlanReviewServer(options: { pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, @@ -373,6 +380,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -381,6 +389,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -442,8 +451,19 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); + } else if ( + url.pathname === "/api/settings-status" && + req.method === "GET" + ) { + json(res, { settingEnabled: false, consentGiven: false }); + } else if ( + url.pathname === "/api/enable-clear-context" && + req.method === "POST" + ) { + json(res, { ok: false, reason: "not-supported-in-pi-extension" }); } else if (url.pathname === "/api/deny" && req.method === "POST") { if (decisionSettled) { json(res, { ok: true, duplicate: true }); diff --git a/apps/skills/extra/plannotator-visual-explainer/references/extended-patterns.md b/apps/skills/extra/plannotator-visual-explainer/references/extended-patterns.md new file mode 100644 index 000000000..65ecad6fd --- /dev/null +++ b/apps/skills/extra/plannotator-visual-explainer/references/extended-patterns.md @@ -0,0 +1,629 @@ +# Extended Patterns + +Components that complement visual-explainer's toolkit. These use the same Plannotator theme tokens from `theme-override.md` and can be mixed freely with Nico's `.ve-card`, `.kpi-card`, `.pipeline` patterns. + +## Timeline + +Vertical timeline showing phases or sequence — without time estimates. Shows ordering and dependencies, not duration. + +```html +
+
+
Phase 1
+
+
+
+
+
+

Foundation

+

Set up the core infrastructure and initial integrations.

+
+
+ +
+``` + +```css +.timeline { display: flex; flex-direction: column; gap: 0; } + +.timeline-item { + display: grid; + grid-template-columns: 100px 28px 1fr; + gap: 16px; + min-height: 80px; +} + +.timeline-label { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--muted-foreground); + text-align: right; + padding-top: 4px; +} + +.timeline-dot-col { + display: flex; + flex-direction: column; + align-items: center; +} + +.timeline-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--card); + border: 3px solid var(--primary); + flex-shrink: 0; +} + +.timeline-dot.active { background: var(--primary); } + +.timeline-line { + width: 2px; + flex: 1; + background: var(--border); +} + +.timeline-content { padding-bottom: 24px; } + +.timeline-content h4 { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 500; + margin-bottom: 4px; +} + +.timeline-content p { + font-size: 0.88rem; + color: var(--muted-foreground); +} +``` + +The last timeline item should hide the line: `style="background: transparent"` on the `.timeline-line`. + +## Code Blocks with Syntax Highlighting + +Dark-themed code panels showing key interfaces, schemas, or API signatures. Use sparingly — show the 5-10 lines that matter, not full files. + +```html +
+ src/api/handler.ts +
interface Config {
+  port: number;
+  host: string;
+}
+
+``` + +```css +.code-panel { + background: var(--code-bg); + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + overflow-x: auto; + margin: 16px 0; +} + +.code-file { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--muted-foreground); + display: block; + margin-bottom: 8px; +} + +.code-panel pre { + margin: 0; + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.55; + color: var(--foreground); + white-space: pre-wrap; + word-break: break-word; +} + +.code-panel .kw { color: var(--primary); } +.code-panel .fn { color: var(--accent); } +.code-panel .str { color: var(--success); } +.code-panel .cm { color: var(--muted-foreground); font-style: italic; } +.code-panel .num { color: var(--warning); } +``` + +## Risk Table + +Severity-graded risk assessment with colored badges. + +```html +
+
+
Database migration on live table
+
HIGH
+
Run during off-peak with online DDL
+
+
+``` + +```css +.risk-grid { + border: 1.5px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.risk-row { + display: grid; + grid-template-columns: 1fr auto 1.5fr; + gap: 24px; + padding: 16px 24px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.risk-row:last-child { border-bottom: none; } +.risk-name { font-weight: 500; } +.risk-mitigation { font-size: 0.9rem; color: var(--muted-foreground); } + +.risk-badge { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.risk-high { + background: color-mix(in oklab, var(--destructive) 15%, transparent); + color: var(--destructive); +} +.risk-med { + background: color-mix(in oklab, var(--warning) 15%, transparent); + color: var(--warning); +} +.risk-low { + background: color-mix(in oklab, var(--success) 15%, transparent); + color: var(--success); +} +``` + +## Open Questions + +Callout cards for unresolved decisions. Each names who can answer. + +```html +
+

Should we use WebSockets or SSE?

+

SSE is simpler but unidirectional. WebSockets add infrastructure complexity.

+ Decide with: infrastructure team +
+``` + +```css +.question { + border-left: 3px solid var(--primary); + padding: 16px 24px; + margin: 16px 0; + background: var(--card); + border-radius: 0 var(--radius) var(--radius) 0; +} + +.question h3 { + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 500; + margin-bottom: 4px; +} + +.question p { + font-size: 0.9rem; + color: var(--muted-foreground); + line-height: 1.55; +} + +.question-owner { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--primary); + font-weight: 500; + display: block; + margin-top: 8px; +} +``` + +## Inline SVG Diagrams + +For architecture, data flow, and simple flowcharts where Mermaid is overkill (under 8 nodes, simple topology). These produce crisp, theme-aware vector diagrams drawn directly in the HTML. Use Mermaid for anything with complex edge routing (10+ nodes, many crossing connections). + +### Container + +```html +
+ + + + Request flow through the API gateway +
+``` + +```css +.svg-panel { + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin: 24px 0; + background: var(--card); +} + +.svg-caption { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--muted-foreground); + display: block; + margin-top: 8px; + text-align: center; +} +``` + +### Arrow markers + +Define in ``. Reference via `marker-end="url(#arrow)"`. + +```svg + + + + + + + + + + + + + + +``` + +### Box node + +```svg + + + API Server + Express + middleware + +``` + +### Highlighted box (new or hot-path component) + +```svg + + + New Service + to be created + +``` + +### Connecting arrows + +```svg + + + + + + + + +``` + +### Edge labels + +```svg +REST +``` + +### Flowchart elements + +```svg + + +Valid? + + + + + + + + + + + + +``` + +### Data flow (request/response) + +```svg + + +POST /api/plan + + + +{ plan, status } +``` + +### Fan-out pattern + +```svg + + + +``` + +### Bar chart + +```svg + + + + + + + + + + + + + + 25 + + + Q1 + +``` + +### Using CSS classes in SVG + +For cleaner markup, define reusable classes inside the SVG: + +```svg + + + +``` + +### Positioning rules + +- `viewBox` with fixed coordinates + `style="width:100%;max-width:720px"` for responsive scaling +- Standard node: `120–160px` wide, `48–56px` tall +- Minimum gap: `60px` horizontal, `40px` vertical +- Arrow label offset: `8–12px` above the line + +### Color roles in SVG + +| Element | Token | +|---------|-------| +| Box background | `var(--card)` | +| Box stroke | `var(--border)` | +| Highlighted box | `var(--primary)` stroke, `color-mix(in oklab, var(--primary) 8%, transparent)` fill | +| Arrows / connectors | `var(--muted-foreground)` | +| Title text | `var(--foreground)` | +| Subtitle / labels | `var(--muted-foreground)` | +| Success path | `var(--success)` | +| Error path | `var(--destructive)` | +| Warning | `var(--warning)` | + +## Section Headers + +Numbered sections with display font headings: + +```css +.section-header { + display: flex; + align-items: baseline; + gap: 14px; + margin-bottom: 24px; + padding-bottom: 8px; + border-bottom: 1.5px solid var(--border); +} + +.section-num { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--primary); +} + +.section-header h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 500; +} +``` + +## Tag Chips + +Small inline labels for categorizing items: + +```css +.tag { + font-family: var(--font-mono); + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 4px; + background: var(--muted); + color: var(--muted-foreground); +} + +.tag-highlight { + background: color-mix(in oklab, var(--primary) 12%, transparent); + color: var(--primary); +} +``` + +## Diff Rendering (for PR reviews) + +Use Pierre diffs via CDN for syntax-highlighted, theme-aware diff rendering. Pierre renders into shadow DOM (no style conflicts) and supports Plannotator theme tokens via CSS variable injection. + +```html + + + +``` + +Pierre handles syntax highlighting (via Shiki), line numbers, add/del coloring, word-level diffs, and split/unified views automatically. The `unsafeCSS` option injects Plannotator theme tokens into the shadow DOM. + +For multiple diffs on one page, create one `` per file and set `fileDiff` + `options` on each. + +## Review Comment Bubbles (for PR reviews) + +Speech bubbles with severity-coded left borders. + +```css +.bubble { + position: relative; + background: var(--card); + border: 1.5px solid var(--border); + border-left-width: 4px; + border-radius: 8px; + padding: 12px 14px 12px 16px; + max-width: 680px; +} + +.bubble.blocking { border-left-color: var(--primary); } +.bubble.nit { border-left-color: var(--border); } +.bubble.suggestion { border-left-color: var(--success); } + +.bubble .severity { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.bubble.blocking .severity { color: var(--primary); } +.bubble.nit .severity { color: var(--muted-foreground); } +.bubble.suggestion .severity { color: var(--success); } +``` + +## File Badges (for PR reviews) + +```css +.file-badge { + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 4px; +} + +.file-badge.new { background: color-mix(in oklab, var(--success) 15%, transparent); color: var(--success); } +.file-badge.mod { background: color-mix(in oklab, var(--warning) 15%, transparent); color: var(--warning); } +.file-badge.del { background: color-mix(in oklab, var(--destructive) 15%, transparent); color: var(--destructive); } +``` diff --git a/packages/editor/App.clearContextBanner.test.ts b/packages/editor/App.clearContextBanner.test.ts new file mode 100644 index 000000000..443eb1776 --- /dev/null +++ b/packages/editor/App.clearContextBanner.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('App clear-context approval UI', () => { + test('does not render the blocking native clear-on-accept prompt', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source).not.toContain('showClearContextBanner'); + expect(source).not.toContain('Enable native clear-on-accept?'); + expect(source).not.toContain('aria-label="Enable native clear-on-accept"'); + }); + + test('gates the native clear setup API behind the shared native-clear predicate', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source.match(/\/api\/enable-clear-context/g)?.length).toBe(1); + expect(source).toContain('shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })'); + expect(source).toContain("clearContextNudge: true"); + }); +}); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index d6a799cab..7fabeb82a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,110 +1,156 @@ -import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; -import { toast, Toaster } from 'sonner'; -import { type Origin, getAgentName } from '@plannotator/shared/agents'; -import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, exportMessageAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry, type MessageAnnotationEntry } from '@plannotator/ui/utils/parser'; -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'; -import { Annotation, Block, EditorMode, type CodeAnnotation, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types'; -import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; -import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip'; -import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; -import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; -import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; -import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; -import { useSharing } from '@plannotator/ui/hooks/useSharing'; -import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator/ui/utils/callback'; -import { useAgents } from '@plannotator/ui/hooks/useAgents'; -import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; -import { storage } from '@plannotator/ui/utils/storage'; -import { configStore, useConfigValue } from '@plannotator/ui/config'; -import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; -import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; -import { PlanAIAnnouncementDialog } from '@plannotator/ui/components/PlanAIAnnouncementDialog'; -import { LookAndFeelAnnouncementDialog } from '@plannotator/ui/components/LookAndFeelAnnouncementDialog'; -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 React, { + useState, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useCallback, +} from "react"; +import { toast, Toaster } from "sonner"; +import { type Origin, getAgentName } from "@plannotator/shared/agents"; import { - getAIProviderSettings, - resolveAIModelForProvider, - resolveAIProviderSelection, - saveAIProviderSelection, -} from '@plannotator/ui/utils/aiProvider'; -import { markPlanAIAnnouncementSeen, needsPlanAIAnnouncement } from '@plannotator/ui/utils/planAIAnnouncement'; -import { markLookAndFeelAnnouncementSeen, needsLookAndFeelAnnouncement } from '@plannotator/ui/utils/lookAndFeelAnnouncement'; -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'; -import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch'; -import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode'; -import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; -import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; -import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; -import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; -import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; -import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; + parseMarkdownToBlocks, + exportAnnotations, + exportLinkedDocAnnotations, + exportEditorAnnotations, + exportCodeFileAnnotations, + extractFrontmatter, + wrapFeedbackForAgent, + Frontmatter, + type LinkedDocAnnotationEntry, +} from "@plannotator/ui/utils/parser"; +import { Viewer, ViewerHandle } from "@plannotator/ui/components/Viewer"; +import { HtmlViewer } from "@plannotator/ui/components/html-viewer"; +import { AnnotationPanel } from "@plannotator/ui/components/AnnotationPanel"; +import { ExportModal } from "@plannotator/ui/components/ExportModal"; +import { ImportModal } from "@plannotator/ui/components/ImportModal"; +import { ConfirmDialog } from "@plannotator/ui/components/ConfirmDialog"; +import { + Annotation, + Block, + EditorMode, + type CodeAnnotation, + type InputMethod, + type ImageAttachment, + type ActionsLabelMode, +} from "@plannotator/ui/types"; +import { ThemeProvider } from "@plannotator/ui/components/ThemeProvider"; +import { Tooltip, TooltipProvider } from "@plannotator/ui/components/Tooltip"; +import { AnnotationToolstrip } from "@plannotator/ui/components/AnnotationToolstrip"; +import { StickyHeaderLane } from "@plannotator/ui/components/StickyHeaderLane"; +import { TaterSpriteRunning } from "@plannotator/ui/components/TaterSpriteRunning"; +import { TaterSpritePullup } from "@plannotator/ui/components/TaterSpritePullup"; +import { useSharing } from "@plannotator/ui/hooks/useSharing"; +import { + getCallbackConfig, + CallbackAction, + executeCallback, +} from "@plannotator/ui/utils/callback"; +import { useAgents } from "@plannotator/ui/hooks/useAgents"; +import { useActiveSection } from "@plannotator/ui/hooks/useActiveSection"; +import { storage } from "@plannotator/ui/utils/storage"; +import { configStore } from "@plannotator/ui/config"; +import { CompletionOverlay } from "@plannotator/ui/components/CompletionOverlay"; +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 { + 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"; +import { useInputMethodSwitch } from "@plannotator/ui/hooks/useInputMethodSwitch"; +import { usePrintMode } from "@plannotator/ui/hooks/usePrintMode"; +import { useResizablePanel } from "@plannotator/ui/hooks/useResizablePanel"; +import { ResizeHandle } from "@plannotator/ui/components/ResizeHandle"; +import { OverlayScrollArea } from "@plannotator/ui/components/OverlayScrollArea"; +import { ScrollViewportContext } from "@plannotator/ui/hooks/useScrollViewport"; +import { useOverlayViewport } from "@plannotator/ui/hooks/useOverlayViewport"; +import { useIsMobile } from "@plannotator/ui/hooks/useIsMobile"; import { getPermissionModeSettings, needsPermissionModeSetup, type PermissionMode, -} from '@plannotator/ui/utils/permissionMode'; -import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; -import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; -import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; -import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar'; -import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; -import { useLinkedDoc, type LinkedDocSessionState } from '@plannotator/ui/hooks/useLinkedDoc'; -import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout'; -import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; -import { useArchive } from '@plannotator/ui/hooks/useArchive'; -import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; -import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; -import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; -import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; -import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; -import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; -import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; -import { generateId } from '@plannotator/ui/utils/generateId'; -import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; -import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; -import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser'; -import type { PickerMessage } from '@plannotator/ui/components/sidebar/MessagesBrowser'; -import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; -import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout'; -import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; +} from "@plannotator/ui/utils/permissionMode"; +import { PermissionModeSetup } from "@plannotator/ui/components/PermissionModeSetup"; +import { ImageAnnotator } from "@plannotator/ui/components/ImageAnnotator"; +import { deriveImageName } from "@plannotator/ui/components/AttachmentsButton"; +import { useSidebar, type SidebarTab } from "@plannotator/ui/hooks/useSidebar"; import { - GoalSetupSurface, - type GoalSetupActionState, - 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'; + usePlanDiff, + type VersionInfo, +} from "@plannotator/ui/hooks/usePlanDiff"; +import { useLinkedDoc } from "@plannotator/ui/hooks/useLinkedDoc"; +import { useCodeFilePopout } from "@plannotator/ui/hooks/useCodeFilePopout"; +import { useAnnotationDraft } from "@plannotator/ui/hooks/useAnnotationDraft"; +import { useArchive } from "@plannotator/ui/hooks/useArchive"; +import { useEditorAnnotations } from "@plannotator/ui/hooks/useEditorAnnotations"; +import { useExternalAnnotations } from "@plannotator/ui/hooks/useExternalAnnotations"; +import { useExternalAnnotationHighlights } from "@plannotator/ui/hooks/useExternalAnnotationHighlights"; +import { buildPlanAgentInstructions } from "@plannotator/ui/utils/planAgentInstructions"; +import { useFileBrowser } from "@plannotator/ui/hooks/useFileBrowser"; +import { isVaultBrowserEnabled } from "@plannotator/ui/utils/obsidian"; +import { + isFileBrowserEnabled, + getFileBrowserSettings, +} from "@plannotator/ui/utils/fileBrowser"; +import { generateId } from "@plannotator/ui/utils/generateId"; +import { SidebarTabs } from "@plannotator/ui/components/sidebar/SidebarTabs"; +import { SidebarContainer } from "@plannotator/ui/components/sidebar/SidebarContainer"; +import type { ArchivedPlan } from "@plannotator/ui/components/sidebar/ArchiveBrowser"; +import { PlanDiffViewer } from "@plannotator/ui/components/plan-diff/PlanDiffViewer"; +import { + CodeFilePopout, + type CodeFileAnnotationInput, +} from "@plannotator/ui/components/CodeFilePopout"; +import type { PlanDiffMode } from "@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher"; // 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 // same env var on the server side so V2/V3 stay paired. -import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; -import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from "./demoPlan"; +import { DIFF_DEMO_PLAN_CONTENT } from "./demoPlanDiffDemo"; +import { + canUseAnnotateWideMode, + resolveWideModeExitLayout, + type WideModeLayoutSnapshot, + type WideModeType, +} from "./wideMode"; +import { + buildApprovalRequestBody, + type ApprovalOverride, +} from "./approvalBody"; +import type { ApproveExtraEntry } from "@plannotator/ui/components/ApproveDropdown"; const USE_DIFF_DEMO = - import.meta.env.VITE_DIFF_DEMO === '1' || - import.meta.env.VITE_DIFF_DEMO === 'true'; + import.meta.env.VITE_DIFF_DEMO === "1" || + import.meta.env.VITE_DIFF_DEMO === "true"; const DEMO_PLAN_CONTENT = USE_DIFF_DEMO ? DIFF_DEMO_PLAN_CONTENT : DEFAULT_DEMO_PLAN_CONTENT; -import { useCheckboxOverrides } from './hooks/useCheckboxOverrides'; -import { AppHeader } from './components/AppHeader'; +import { useCheckboxOverrides } from "./hooks/useCheckboxOverrides"; +import { AppHeader } from "./components/AppHeader"; type NoteAutoSaveResults = { obsidian?: boolean; @@ -112,83 +158,20 @@ type NoteAutoSaveResults = { octarine?: boolean; }; -type MessageAnnotationState = { - messageId: string; - text: string; - timestamp?: string; - linkedDocSession: LinkedDocSessionState; - codeAnnotations: CodeAnnotation[]; - selectedCodeAnnotationId: string | null; -}; - -const countLinkedDocSessionAnnotations = (session: LinkedDocSessionState): number => { - let total = - session.root.annotations.length + - session.root.globalAttachments.length; - for (const doc of session.docs.values()) { - total += doc.annotations.length + doc.globalAttachments.length; - } - return total; -}; - -const countMessageAnnotations = (state: MessageAnnotationState): number => - countLinkedDocSessionAnnotations(state.linkedDocSession) + - state.codeAnnotations.length; - -const createEmptyMessageState = (message: PickerMessage): MessageAnnotationState => ({ - messageId: message.messageId, - text: message.text, - timestamp: message.timestamp, - linkedDocSession: { - root: { - markdown: message.text, - annotations: [], - selectedAnnotationId: null, - globalAttachments: [], - }, - docs: new Map(), - }, - codeAnnotations: [], - selectedCodeAnnotationId: null, -}); - -const normalizeMessageState = ( - state: MessageAnnotationState, - message: PickerMessage, -): MessageAnnotationState => ({ - ...state, - text: message.text, - timestamp: message.timestamp, - linkedDocSession: { - root: { - ...state.linkedDocSession.root, - // The root document for a message is immutable and comes from the picker. - // Keep it as the source of truth so transient UI state cannot cache an - // empty markdown value for a message. - markdown: message.text, - }, - docs: new Map(state.linkedDocSession.docs), - }, -}); - -const buildMessageAnnotationCounts = ( - states: Map -): Map => { - const counts = new Map(); - for (const [messageId, state] of states) { - const count = countMessageAnnotations(state); - if (count > 0) counts.set(messageId, count); - } - return counts; -}; - const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); const [codeAnnotations, setCodeAnnotations] = useState([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); - const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState(null); - const frontmatter = useMemo(() => extractFrontmatter(markdown).frontmatter, [markdown]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState< + string | null + >(null); + const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState< + string | null + >(null); + const frontmatter = useMemo( + () => extractFrontmatter(markdown).frontmatter, + [markdown], + ); const blocks = useMemo(() => parseMarkdownToBlocks(markdown), [markdown]); const [showExport, setShowExport] = useState(false); const [showImport, setShowImport] = useState(false); @@ -196,19 +179,21 @@ const App: React.FC = () => { const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. - const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); + const [exitWarningAction, setExitWarningAction] = useState< + "close" | "approve" + >("close"); const [showAgentWarning, setShowAgentWarning] = useState(false); - const [agentWarningMessage, setAgentWarningMessage] = useState(''); - const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); - const [rightSidebarTab, setRightSidebarTab] = useState<'annotations' | 'ai'>('annotations'); + const [agentWarningMessage, setAgentWarningMessage] = useState(""); + const [isPanelOpen, setIsPanelOpen] = useState( + () => window.innerWidth >= 768, + ); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false); const [editorMode, setEditorMode] = useState(getEditorMode); const [inputMethod, setInputMethod] = useState(getInputMethod); const [taterMode, setTaterMode] = useState(() => { - const stored = storage.getItem('plannotator-tater-mode'); - return stored === 'true'; + const stored = storage.getItem("plannotator-tater-mode"); + return stored === "true"; }); - const gridEnabled = useConfigValue('gridEnabled'); const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); // Plan-area width (inside the OverlayScrollArea, after sidebar/panel @@ -222,98 +207,95 @@ const App: React.FC = () => { // short → "Comment" / "Copy" — fits when planArea >= 680 // icon → labels hidden — fallback below that const planAreaRef = useRef(null); - const [actionsLabelMode, setActionsLabelMode] = useState('full'); + const [actionsLabelMode, setActionsLabelMode] = + useState("full"); + // useLayoutEffect + synchronous getBoundingClientRect so the initial + // bucket is set before the browser paints. Otherwise narrow viewports + // get a one-frame flash of "Global comment"/"Copy plan" labels before + // the ResizeObserver callback collapses them. + useLayoutEffect(() => { + const el = planAreaRef.current; + if (!el) return; + const bucket = (w: number): ActionsLabelMode => + w >= 800 ? "full" : w >= 680 ? "short" : "icon"; + setActionsLabelMode(bucket(el.getBoundingClientRect().width)); + const ro = new ResizeObserver(([entry]) => { + const next = bucket(entry.contentRect.width); + setActionsLabelMode((prev) => (prev === next ? prev : next)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); + const [pendingToolName, setPendingToolName] = useState(); + const [showClearContextBanner, setShowClearContextBanner] = useState(false); + // Once the user has Enabled or Skipped the native clear-on-accept consent, + // we must not re-show the banner on subsequent Approve clicks. This guards + // against the consent prompt re-triggering indefinitely. + const clearContextConsentResolvedRef = useRef(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = + useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); - const updateInfo = useUpdateCheck(); - const updateToastShown = useRef(false); - useEffect(() => { - if (window.location.hash) return; - if (updateInfo?.updateAvailable && !updateInfo.dismissed && !updateToastShown.current) { - updateToastShown.current = true; - const t = setTimeout(() => { - toast('A new version of Plannotator is available', { - description: 'Open the Options menu to update.', - duration: 4000, - classNames: { toast: '!w-auto', description: '!text-foreground/70' }, - }); - }, 1500); - return () => clearTimeout(t); - } - }, [updateInfo?.updateAvailable, updateInfo?.dismissed]); - const [globalAttachments, setGlobalAttachments] = useState([]); + const [globalAttachments, setGlobalAttachments] = useState( + [], + ); const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); - const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null); - const [recentMessages, setRecentMessages] = useState([]); - const [selectedMessageId, setSelectedMessageId] = useState(null); - const messageStateCacheRef = useRef>(new Map()); - const [cachedMessageAnnotationCounts, setCachedMessageAnnotationCounts] = useState>(new Map()); - const [goalSetupBundle, setGoalSetupBundle] = useState(null); - const goalSetupSurfaceRef = useRef(null); - const [goalSetupAction, setGoalSetupAction] = useState({ - canSubmit: false, - isSubmitting: false, - submitted: false, - submitLabel: 'Submit', - }); + const [annotateSource, setAnnotateSource] = useState< + "file" | "message" | "folder" | null + >(null); const [sourceInfo, setSourceInfo] = useState(); const [sourceConverted, setSourceConverted] = useState(false); - const [renderAs, setRenderAs] = useState<'markdown' | 'html'>('markdown'); - // HTML plans render edge-to-edge (full-viewport) instead of in the centered, - // card-chromed markdown column. Branch the document-area containers on this. - const isHtmlSurface = renderAs === 'html'; - const [rawHtml, setRawHtml] = useState(''); - // Session-level force-markdown preference (`--markdown`). When set, folder/linked HTML - // files are converted instead of rendered raw — threaded into /api/doc as &convert=1. - const [convertHtml, setConvertHtml] = useState(false); - // Hide the floating HTML annotation controls (toolstrip + action cluster) so the - // user can read the rendered page unobstructed. Selections/annotations are unaffected. - const [htmlToolsHidden, setHtmlToolsHidden] = useState(false); + const [renderAs, setRenderAs] = useState<"markdown" | "html">("markdown"); + const [rawHtml, setRawHtml] = useState(""); const [sourceFilePath, setSourceFilePath] = useState(); - const [imageBaseDir, setImageBaseDir] = useState(undefined); + const [imageBaseDir, setImageBaseDir] = useState( + undefined, + ); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [isExiting, setIsExiting] = useState(false); - const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null); - const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); + const [submitted, setSubmitted] = useState< + "approved" | "denied" | "exited" | null + >(null); + const [pendingPasteImage, setPendingPasteImage] = useState<{ + file: File; + blobUrl: string; + initialName: string; + } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); - const [permissionMode, setPermissionMode] = useState('bypassPermissions'); + const [permissionMode, setPermissionMode] = + useState("bypassPermissions"); const [sharingEnabled, setSharingEnabled] = useState(true); - const [shareBaseUrl, setShareBaseUrl] = useState(undefined); + const [shareBaseUrl, setShareBaseUrl] = useState( + undefined, + ); const [pasteApiUrl, setPasteApiUrl] = useState(undefined); - const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string; host?: string } | null>(null); + const [repoInfo, setRepoInfo] = useState<{ + display: string; + branch?: string; + host?: string; + } | null>(null); const [projectRoot, setProjectRoot] = useState(null); const [wideModeType, setWideModeType] = useState(null); const wideModeSnapshotRef = useRef(null); const lastAppliedTocEnabledRef = useRef(uiPrefs.tocEnabled); - const goalSetupMode = goalSetupBundle !== null; useEffect(() => { - document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; + document.title = repoInfo + ? `${repoInfo.display} · Plannotator` + : "Plannotator"; }, [repoInfo]); - const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>(); + const [initialExportTab, setInitialExportTab] = useState< + "share" | "annotations" | "notes" + >(); const [isPlanDiffActive, setIsPlanDiffActive] = useState(false); - const [planDiffMode, setPlanDiffMode] = useState('clean'); + 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 [showLookAndFeelAnnouncement, setShowLookAndFeelAnnouncement] = useState(needsLookAndFeelAnnouncement); const isMobile = useIsMobile(); const viewerRef = useRef(null); @@ -329,123 +311,106 @@ const App: React.FC = () => { usePrintMode(); - // Sidebar (shared TOC + Version Browser) - const sidebar = useSidebar(getUIPreferences().tocEnabled); - // Resizable panels const panelResize = useResizablePanel({ - storageKey: 'plannotator-panel-width', - // Drag the right panel skinny → snap it shut (matches the contents sidebar). - onSnapClose: () => setIsPanelOpen(false), - // Render-free drag: write the live width to a :root var the panel reads, - // so dragging never re-renders this (heavy) App. - apply: (w) => document.documentElement.style.setProperty('--rpanel-w', `${w}px`), + storageKey: "plannotator-panel-width", }); const tocResize = useResizablePanel({ - storageKey: 'plannotator-toc-width', - defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left', - // Drag the contents panel skinny → snap it shut (prototype behavior). - onSnapClose: sidebar.close, - // Render-free drag: write the live width to a :root var the panel reads. - apply: (w) => document.documentElement.style.setProperty('--toc-w', `${w}px`), + storageKey: "plannotator-toc-width", + defaultWidth: 240, + minWidth: 160, + maxWidth: 400, + side: "left", }); const isResizing = panelResize.isDragging || tocResize.isDragging; + // Sidebar (shared TOC + Version Browser) + const sidebar = useSidebar(getUIPreferences().tocEnabled); + // Whether the document has any TOC-eligible headings (level <= 3, matching // buildTocHierarchy). Drives the empty-doc auto-close behavior below — must // be declared before the effects that reference it (TDZ in dep arrays). const hasTocEntries = useMemo( - () => blocks.some(b => b.type === 'heading' && (b.level ?? 0) <= 3), - [blocks] + () => blocks.some((b) => b.type === "heading" && (b.level ?? 0) <= 3), + [blocks], ); - const exitWideMode = useCallback((options?: { - restore?: boolean; - sidebarTab?: SidebarTab; - panelOpen?: boolean; - }) => { - if (wideModeType === null) { - if (options?.sidebarTab) sidebar.open(options.sidebarTab); - if (options?.panelOpen === true) setIsPanelOpen(true); - else if (options?.panelOpen === false) setIsPanelOpen(false); - return; - } + const exitWideMode = useCallback( + (options?: { + restore?: boolean; + sidebarTab?: SidebarTab; + panelOpen?: boolean; + }) => { + if (wideModeType === null) { + if (options?.sidebarTab) sidebar.open(options.sidebarTab); + if (options?.panelOpen === true) setIsPanelOpen(true); + else if (options?.panelOpen === false) setIsPanelOpen(false); + return; + } - const snapshot = wideModeSnapshotRef.current; - const layout = resolveWideModeExitLayout(snapshot, options); + const snapshot = wideModeSnapshotRef.current; + const layout = resolveWideModeExitLayout(snapshot, options); - setWideModeType(null); - wideModeSnapshotRef.current = null; + setWideModeType(null); + wideModeSnapshotRef.current = null; - if (layout.sidebarOpen && layout.sidebarTab) { - sidebar.open(layout.sidebarTab); - } else { - sidebar.close(); - } + if (layout.sidebarOpen && layout.sidebarTab) { + sidebar.open(layout.sidebarTab); + } else { + sidebar.close(); + } - if (layout.panelOpen !== undefined) { - setIsPanelOpen(layout.panelOpen); - } - }, [wideModeType, sidebar.close, sidebar.open]); + if (layout.panelOpen !== undefined) { + setIsPanelOpen(layout.panelOpen); + } + }, + [wideModeType, sidebar.close, sidebar.open], + ); - const openSidebarTab = useCallback((tab: SidebarTab) => { - if (wideModeType !== null) { - exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); - return; - } - sidebar.open(tab); - }, [exitWideMode, wideModeType, sidebar.open]); + const openSidebarTab = useCallback( + (tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.open(tab); + }, + [exitWideMode, wideModeType, sidebar.open], + ); - const toggleSidebarTab = useCallback((tab: SidebarTab) => { - if (wideModeType !== null) { - exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); - return; - } - sidebar.toggleTab(tab); - }, [exitWideMode, wideModeType, sidebar.toggleTab]); + const toggleSidebarTab = useCallback( + (tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.toggleTab(tab); + }, + [exitWideMode, wideModeType, sidebar.toggleTab], + ); 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 dismissLookAndFeelAnnouncement = useCallback(() => { - markLookAndFeelAnnouncementSeen(); - setShowLookAndFeelAnnouncement(false); - }, []); + setIsPanelOpen((prev) => !prev); + }, [exitWideMode, wideModeType]); - 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 the "Auto-open Sidebar" preference changes in - // Settings. Deliberately does NOT react to the document or render mode — - // switching files (e.g. in annotate-folder) leaves the sidebar exactly as the - // user left it. + // Sync sidebar open state when preference changes in Settings useEffect(() => { if (wideModeType !== null) return; if (lastAppliedTocEnabledRef.current === uiPrefs.tocEnabled) return; lastAppliedTocEnabledRef.current = uiPrefs.tocEnabled; - if (uiPrefs.tocEnabled && hasTocEntries) sidebar.open('toc'); + if (uiPrefs.tocEnabled && hasTocEntries) sidebar.open("toc"); else if (!uiPrefs.tocEnabled) sidebar.close(); - }, [wideModeType, sidebar.close, sidebar.open, uiPrefs.tocEnabled, hasTocEntries]); + }, [ + wideModeType, + sidebar.close, + sidebar.open, + uiPrefs.tocEnabled, + hasTocEntries, + ]); // Auto-close the sidebar when blocks parse with no TOC entries. Fires // only on blocks/hasTocEntries change (not on sidebar state) so a user @@ -454,7 +419,7 @@ const App: React.FC = () => { useEffect(() => { if (blocks.length === 0) return; if (hasTocEntries) return; - if (sidebar.activeTab === 'toc' && sidebar.isOpen) { + if (sidebar.activeTab === "toc" && sidebar.isOpen) { sidebar.close(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -462,7 +427,7 @@ const App: React.FC = () => { // Clear diff view when switching away from versions tab useEffect(() => { - if (sidebar.activeTab === 'toc' && isPlanDiffActive) { + if (sidebar.activeTab === "toc" && isPlanDiffActive) { setIsPlanDiffActive(false); } }, [sidebar.activeTab]); @@ -471,88 +436,127 @@ const App: React.FC = () => { useEffect(() => { if (!isPlanDiffActive) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { setIsPlanDiffActive(false); } }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [isPlanDiffActive]); // Plan diff computation const planDiff = usePlanDiff(markdown, previousPlan, versionInfo); - const linkedDocSidebar = useMemo(() => ({ - ...sidebar, - open: openSidebarTab, - toggleTab: toggleSidebarTab, - }), [ - openSidebarTab, - sidebar.activeTab, - sidebar.close, - sidebar.isOpen, - toggleSidebarTab, - ]); + const linkedDocSidebar = useMemo( + () => ({ + ...sidebar, + open: openSidebarTab, + toggleTab: toggleSidebarTab, + }), + [ + openSidebarTab, + sidebar.activeTab, + sidebar.close, + sidebar.isOpen, + toggleSidebarTab, + ], + ); // Linked document navigation const linkedDocHook = useLinkedDoc({ - markdown, annotations, selectedAnnotationId, globalAttachments, - setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, - renderAs, rawHtml, setRenderAs, setRawHtml, - viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted, + markdown, + annotations, + selectedAnnotationId, + globalAttachments, + setMarkdown, + setAnnotations, + setSelectedAnnotationId, + setGlobalAttachments, + viewerRef, + sidebar: linkedDocSidebar, + sourceFilePath, + sourceConverted, }); // Active document's directory — feeds both click-time popout fetches and // the validator hook so they resolve against the same base. Drifting // these would silently re-introduce the demote-correct-link bug. const activeDocBaseDir = useMemo( - () => linkedDocHook.filepath - ? linkedDocHook.filepath.replace(/\/[^/]+$/, '') - : imageBaseDir?.includes('/') ? imageBaseDir : undefined, + () => + linkedDocHook.filepath + ? linkedDocHook.filepath.replace(/\/[^/]+$/, "") + : imageBaseDir?.includes("/") + ? imageBaseDir + : undefined, [linkedDocHook.filepath, imageBaseDir], ); // Code file popout (read-only syntax-highlighted overlay) const codeFilePopout = useCodeFilePopout({ - buildUrl: useCallback((codePath: string) => { - return activeDocBaseDir - ? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(activeDocBaseDir)}` - : `/api/doc?path=${encodeURIComponent(codePath)}`; - }, [activeDocBaseDir]), + buildUrl: useCallback( + (codePath: string) => { + return activeDocBaseDir + ? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(activeDocBaseDir)}` + : `/api/doc?path=${encodeURIComponent(codePath)}`; + }, + [activeDocBaseDir], + ), }); // Archive browser const archive = useArchive({ - markdown, viewerRef, linkedDocHook, - setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted, + markdown, + viewerRef, + linkedDocHook, + setMarkdown, + setAnnotations, + setSelectedAnnotationId, + setSubmitted, }); - const canUseWideMode = useMemo(() => canUseAnnotateWideMode({ - archiveMode: archive.archiveMode, - isPlanDiffActive, - }), [archive.archiveMode, isPlanDiffActive]); + const canUseWideMode = useMemo( + () => + canUseAnnotateWideMode({ + archiveMode: archive.archiveMode, + isPlanDiffActive, + }), + [archive.archiveMode, isPlanDiffActive], + ); - const enterViewMode = useCallback((type: WideModeType) => { - if (!canUseWideMode) return; - if (wideModeType === null) { - wideModeSnapshotRef.current = { - sidebarIsOpen: sidebar.isOpen, - sidebarTab: sidebar.activeTab, - panelOpen: isPanelOpen, - }; - } - setWideModeType(type); - sidebar.close(); - setIsPanelOpen(false); - }, [canUseWideMode, isPanelOpen, wideModeType, sidebar.activeTab, sidebar.close, sidebar.isOpen]); + const enterViewMode = useCallback( + (type: WideModeType) => { + if (!canUseWideMode) return; + if (wideModeType === null) { + wideModeSnapshotRef.current = { + sidebarIsOpen: sidebar.isOpen, + sidebarTab: sidebar.activeTab, + panelOpen: isPanelOpen, + }; + } + setWideModeType(type); + sidebar.close(); + setIsPanelOpen(false); + }, + [ + canUseWideMode, + isPanelOpen, + wideModeType, + sidebar.activeTab, + sidebar.close, + sidebar.isOpen, + ], + ); - const toggleViewMode = useCallback((type: WideModeType) => { - if (wideModeType === type) { - exitWideMode(); - } else { - enterViewMode(type); - } - }, [enterViewMode, exitWideMode, wideModeType]); + const toggleViewMode = useCallback( + (type: WideModeType) => { + if (wideModeType === type) { + exitWideMode(); + } else { + enterViewMode(type); + } + }, + [enterViewMode, exitWideMode, wideModeType], + ); useEffect(() => { if (!canUseWideMode && wideModeType !== null) { @@ -563,12 +567,12 @@ const App: React.FC = () => { // Markdown file browser (also handles vault dirs via isVault flag) const fileBrowser = useFileBrowser(); const vaultPath = useMemo(() => { - if (!isVaultBrowserEnabled()) return ''; + if (!isVaultBrowserEnabled()) return ""; return getEffectiveVaultPath(getObsidianSettings()); }, [uiPrefs]); const showFilesTab = useMemo( () => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(), - [projectRoot, uiPrefs] + [projectRoot, uiPrefs], ); const fileBrowserDirs = useMemo(() => { const projectDirs = projectRoot ? [projectRoot] : []; @@ -589,163 +593,94 @@ const App: React.FC = () => { }, [vaultPath]); useEffect(() => { - if (sidebar.activeTab === 'files' && showFilesTab) { + if (sidebar.activeTab === "files" && showFilesTab) { // Load regular dirs if (fileBrowserDirs.length > 0) { - const regularLoaded = fileBrowser.dirs.filter(d => !d.isVault).map(d => d.path); - const needsRegular = fileBrowserDirs.some(d => !regularLoaded.includes(d)) - || regularLoaded.some(d => !fileBrowserDirs.includes(d)); + const regularLoaded = fileBrowser.dirs + .filter((d) => !d.isVault) + .map((d) => d.path); + const needsRegular = + fileBrowserDirs.some((d) => !regularLoaded.includes(d)) || + regularLoaded.some((d) => !fileBrowserDirs.includes(d)); if (needsRegular) fileBrowser.fetchAll(fileBrowserDirs); } // Load vault dir; addVaultDir atomically replaces any existing vault entry so // switching vault paths never accumulates stale sections - if (vaultPath && !fileBrowser.dirs.find(d => d.isVault && d.path === vaultPath && !d.error)) { + if ( + vaultPath && + !fileBrowser.dirs.find( + (d) => d.isVault && d.path === vaultPath && !d.error, + ) + ) { fileBrowser.addVaultDir(vaultPath); } } }, [sidebar.activeTab, showFilesTab, fileBrowserDirs, vaultPath]); - const buildCurrentMessageState = React.useCallback((): MessageAnnotationState | null => { - if (annotateSource !== 'message' || !selectedMessageId) return null; - const msg = recentMessages.find((m) => m.messageId === selectedMessageId); - if (!msg) return null; - const snapshot = linkedDocHook.snapshotSession(); - return normalizeMessageState({ - messageId: msg.messageId, - text: msg.text, - timestamp: msg.timestamp, - linkedDocSession: snapshot, - codeAnnotations: [...codeAnnotations], - selectedCodeAnnotationId, - }, msg); - }, [ - annotateSource, - selectedMessageId, - recentMessages, - linkedDocHook.snapshotSession, - codeAnnotations, - selectedCodeAnnotationId, - ]); - - const getMessageStatesWithCurrent = React.useCallback((): Map => { - const states = new Map(messageStateCacheRef.current); - const current = buildCurrentMessageState(); - if (current) states.set(current.messageId, current); - return states; - }, [buildCurrentMessageState]); - - const saveCurrentMessageState = React.useCallback((): Map => { - const states = getMessageStatesWithCurrent(); - messageStateCacheRef.current = states; - setCachedMessageAnnotationCounts(buildMessageAnnotationCounts(states)); - return states; - }, [getMessageStatesWithCurrent]); - - const buildMessageAnnotationEntries = React.useCallback((): MessageAnnotationEntry[] => { - if (annotateSource !== 'message' || recentMessages.length === 0) return []; - const states = saveCurrentMessageState(); - return recentMessages.map((msg) => { - const state = states.get(msg.messageId) ?? createEmptyMessageState(msg); - const linkedDocs: Map = new Map(); - for (const [filepath, doc] of state.linkedDocSession.docs) { - linkedDocs.set(filepath, { - ...doc, - blocks: doc.markdown ? parseMarkdownToBlocks(doc.markdown) : undefined, - }); - } - return { - messageId: msg.messageId, - text: msg.text, - timestamp: msg.timestamp, - annotations: state.linkedDocSession.root.annotations, - globalAttachments: state.linkedDocSession.root.globalAttachments, - blocks: parseMarkdownToBlocks(state.linkedDocSession.root.markdown), - linkedDocs, - codeAnnotations: state.codeAnnotations, - }; - }); - }, [annotateSource, recentMessages, saveCurrentMessageState]); - - const activeMessageAnnotationCounts = React.useMemo(() => { - const counts = new Map(cachedMessageAnnotationCounts); - const current = buildCurrentMessageState(); - if (current) { - const count = countMessageAnnotations(current); - if (count > 0) counts.set(current.messageId, count); - else counts.delete(current.messageId); - } - return counts; - }, [cachedMessageAnnotationCounts, buildCurrentMessageState]); - - const messageFeedbackAnnotationCount = React.useMemo( - () => Array.from(activeMessageAnnotationCounts.values()).reduce((sum, count) => sum + count, 0), - [activeMessageAnnotationCounts] - ); - - const annotatedMessageIds = React.useMemo( - () => Array.from(activeMessageAnnotationCounts.keys()), - [activeMessageAnnotationCounts] - ); - // File browser file selection: open via linked doc system // For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc - const handleSelectMessage = React.useCallback((messageId: string) => { - const msg = recentMessages.find((m) => m.messageId === messageId); - if (!msg || messageId === selectedMessageId) return; - - const states = saveCurrentMessageState(); - const targetState = normalizeMessageState( - states.get(messageId) ?? createEmptyMessageState(msg), - msg, - ); - - setSelectedMessageId(messageId); - linkedDocHook.restoreSession(targetState.linkedDocSession); - setCodeAnnotations([...targetState.codeAnnotations]); - setSelectedCodeAnnotationId(targetState.selectedCodeAnnotationId); - }, [ - recentMessages, - selectedMessageId, - saveCurrentMessageState, - linkedDocHook.restoreSession, - ]); - - const handleFileBrowserSelect = React.useCallback((absolutePath: string, dirPath: string) => { - const dirState = fileBrowser.dirs.find(d => d.path === dirPath); - const buildUrl = dirState?.isVault - ? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}` - : (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}${convertHtml ? '&convert=1' : ''}`; - linkedDocHook.open(absolutePath, buildUrl, 'files'); - fileBrowser.setActiveFile(absolutePath); - }, [linkedDocHook, fileBrowser, convertHtml]); + const handleFileBrowserSelect = React.useCallback( + (absolutePath: string, dirPath: string) => { + const dirState = fileBrowser.dirs.find((d) => d.path === dirPath); + const buildUrl = dirState?.isVault + ? (path: string) => + `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}` + : (path: string) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`; + linkedDocHook.open(absolutePath, buildUrl, "files"); + fileBrowser.setActiveFile(absolutePath); + }, + [linkedDocHook, fileBrowser], + ); // Route linked doc opens through the correct endpoint based on current context - const handleOpenLinkedDoc = React.useCallback((docPath: string) => { - const activeDirState = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath); - if (activeDirState?.isVault && fileBrowser.activeDirPath) { - linkedDocHook.open(docPath, (path) => - `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}` + const handleOpenLinkedDoc = React.useCallback( + (docPath: string) => { + const activeDirState = fileBrowser.dirs.find( + (d) => d.path === fileBrowser.activeDirPath, ); - } else if (fileBrowser.activeFile && fileBrowser.activeDirPath) { - // When viewing a file browser doc, resolve links relative to current file's directory - const baseDir = linkedDocHook.filepath?.replace(/\/[^/]+$/, '') || fileBrowser.activeDirPath; - linkedDocHook.open(docPath, (path) => - `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}${convertHtml ? '&convert=1' : ''}` - ); - } else { - // Pass the current file's directory as base for relative path resolution - const baseDir = linkedDocHook.filepath - ? linkedDocHook.filepath.replace(/\/[^/]+$/, '') - : imageBaseDir?.includes('/') ? imageBaseDir : undefined; - if (baseDir) { - linkedDocHook.open(docPath, (path) => - `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}${convertHtml ? '&convert=1' : ''}` + if (activeDirState?.isVault && fileBrowser.activeDirPath) { + linkedDocHook.open( + docPath, + (path) => + `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}`, + ); + } else if (fileBrowser.activeFile && fileBrowser.activeDirPath) { + // When viewing a file browser doc, resolve links relative to current file's directory + const baseDir = + linkedDocHook.filepath?.replace(/\/[^/]+$/, "") || + fileBrowser.activeDirPath; + linkedDocHook.open( + docPath, + (path) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}`, ); } else { - linkedDocHook.open(docPath); + // Pass the current file's directory as base for relative path resolution + const baseDir = linkedDocHook.filepath + ? linkedDocHook.filepath.replace(/\/[^/]+$/, "") + : imageBaseDir?.includes("/") + ? imageBaseDir + : undefined; + if (baseDir) { + linkedDocHook.open( + docPath, + (path) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}`, + ); + } else { + linkedDocHook.open(docPath); + } } - } - }, [fileBrowser.dirs, fileBrowser.activeDirPath, fileBrowser.activeFile, linkedDocHook, imageBaseDir, convertHtml]); + }, + [ + fileBrowser.dirs, + fileBrowser.activeDirPath, + fileBrowser.activeFile, + linkedDocHook, + imageBaseDir, + ], + ); // Wrap linked doc back to also clear file browser active file const handleLinkedDocBack = React.useCallback(() => { @@ -766,11 +701,11 @@ const App: React.FC = () => { // FileBrowser counts: all files under any loaded dir (regular + vault) const fileAnnotationCounts = useMemo(() => { - const allDirPaths = fileBrowser.dirs.map(d => d.path); + const allDirPaths = fileBrowser.dirs.map((d) => d.path); if (allDirPaths.length === 0) return allAnnotationCounts; const counts = new Map(); for (const [fp, count] of allAnnotationCounts) { - if (allDirPaths.some(dir => fp.startsWith(dir + '/'))) { + if (allDirPaths.some((dir) => fp.startsWith(dir + "/"))) { counts.set(fp, count); } } @@ -794,14 +729,16 @@ const App: React.FC = () => { }, [allAnnotationCounts, linkedDocHook.filepath]); // Flash highlight for annotated files in the sidebar - const [highlightedFiles, setHighlightedFiles] = useState | undefined>(); + const [highlightedFiles, setHighlightedFiles] = useState< + Set | undefined + >(); const flashTimerRef = React.useRef>(); const handleFlashAnnotatedFiles = React.useCallback(() => { const filePaths = new Set(allAnnotationCounts.keys()); if (filePaths.size === 0) return; // Open sidebar to the files tab so the flash is visible - if (!sidebar.isOpen || sidebar.activeTab !== 'files') { - openSidebarTab('files'); + if (!sidebar.isOpen || sidebar.activeTab !== "files") { + openSidebarTab("files"); } // Cancel any pending clear from a previous flash if (flashTimerRef.current) clearTimeout(flashTimerRef.current); @@ -809,32 +746,40 @@ const App: React.FC = () => { setHighlightedFiles(undefined); requestAnimationFrame(() => { setHighlightedFiles(filePaths); - flashTimerRef.current = setTimeout(() => setHighlightedFiles(undefined), 1200); + flashTimerRef.current = setTimeout( + () => setHighlightedFiles(undefined), + 1200, + ); }); }, [allAnnotationCounts, openSidebarTab, sidebar, hasFileAnnotations]); // Context-aware back label for linked doc navigation - const backLabel = annotateSource === 'folder' ? 'file list' - : annotateSource === 'file' ? 'file' - : annotateSource === 'message' ? 'message' - : 'plan'; - - // Viewer identity must change when the rendered document changes: web-highlighter - // mutates the Viewer DOM, so reconciling new content against the old subtree throws - // removeChild errors — a changed key remounts it cleanly instead. StickyHeaderLane - // observes a node inside Viewer, so it re-anchors off the same token. - const viewerContentKey = linkedDocHook.isActive - ? `doc:${linkedDocHook.filepath}` - : annotateSource === 'message' && selectedMessageId - ? `msg:${selectedMessageId}` - : 'plan'; + const backLabel = + annotateSource === "folder" + ? "file list" + : annotateSource === "file" + ? "file" + : annotateSource === "message" + ? "message" + : "plan"; // Track active section for TOC highlighting - const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]); - const activeSection = useActiveSection(containerRef, headingCount, scrollViewport); + const headingCount = useMemo( + () => blocks.filter((b) => b.type === "heading").length, + [blocks], + ); + const activeSection = useActiveSection( + containerRef, + headingCount, + scrollViewport, + ); const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); - const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: isApiMode && !goalSetupMode }); + const { + externalAnnotations, + updateExternalAnnotation, + deleteExternalAnnotation, + } = useExternalAnnotations({ enabled: isApiMode }); // Drive DOM highlights for SSE-delivered external annotations. Disabled // while a linked doc overlay is open (Viewer DOM is hidden) and while the @@ -842,7 +787,7 @@ const App: React.FC = () => { const { reset: resetExternalHighlights } = useExternalAnnotationHighlights({ viewerRef, externalAnnotations, - enabled: isApiMode && !goalSetupMode && !linkedDocHook.isActive && !isPlanDiffActive, + enabled: isApiMode && !linkedDocHook.isActive && !isPlanDiffActive, planKey: markdown, }); @@ -853,12 +798,13 @@ const App: React.FC = () => { const allAnnotations = useMemo(() => { if (externalAnnotations.length === 0) return annotations; - const local = annotations.filter(a => { + const local = annotations.filter((a) => { if (!a.source) return true; - return !externalAnnotations.some(ext => - ext.source === a.source && - ext.type === a.type && - ext.originalText === a.originalText + return !externalAnnotations.some( + (ext) => + ext.source === a.source && + ext.type === a.type && + ext.originalText === a.originalText, ); }); @@ -866,22 +812,24 @@ const App: React.FC = () => { }, [annotations, externalAnnotations]); // Plan diff state — memoize filtered annotation lists to avoid new references per render - const diffAnnotations = useMemo(() => allAnnotations.filter(a => !!a.diffContext), [allAnnotations]); - const viewerAnnotations = useMemo(() => allAnnotations.filter(a => !a.diffContext), [allAnnotations]); + const diffAnnotations = useMemo( + () => allAnnotations.filter((a) => !!a.diffContext), + [allAnnotations], + ); + const viewerAnnotations = useMemo( + () => allAnnotations.filter((a) => !a.diffContext), + [allAnnotations], + ); // Any-annotations flag used by Close/Approve/Send guards. Consolidates the // four-term check that was inlined across the annotate-mode header + keyboard paths. - const messageMultiSelectMode = annotateSource === 'message' && recentMessages.length > 1; const hasAnyAnnotations = useMemo( - () => messageMultiSelectMode - ? messageFeedbackAnnotationCount > 0 || editorAnnotations.length > 0 - : allAnnotations.length > 0 - || codeAnnotations.length > 0 - || editorAnnotations.length > 0 - || linkedDocHook.docAnnotationCount > 0 - || globalAttachments.length > 0, + () => + allAnnotations.length > 0 || + codeAnnotations.length > 0 || + editorAnnotations.length > 0 || + linkedDocHook.docAnnotationCount > 0 || + globalAttachments.length > 0, [ - messageMultiSelectMode, - messageFeedbackAnnotationCount, allAnnotations.length, codeAnnotations.length, editorAnnotations.length, @@ -889,13 +837,12 @@ const App: React.FC = () => { globalAttachments.length, ], ); - const feedbackAnnotationCount = messageMultiSelectMode - ? messageFeedbackAnnotationCount + editorAnnotations.length - : allAnnotations.length + - codeAnnotations.length + - editorAnnotations.length + - linkedDocHook.docAnnotationCount + - globalAttachments.length; + const feedbackAnnotationCount = + allAnnotations.length + + codeAnnotations.length + + editorAnnotations.length + + linkedDocHook.docAnnotationCount + + globalAttachments.length; // Code-file comments are intentionally not serialized into share URLs in v1. // Hide share entry points once they exist so we do not silently drop feedback. const canShareCurrentSession = sharingEnabled && codeAnnotations.length === 0; @@ -934,51 +881,45 @@ const App: React.FC = () => { setRenderAs, ); - // useLayoutEffect + synchronous getBoundingClientRect so the initial - // bucket is set before the browser paints. Otherwise narrow viewports - // get a one-frame flash of "Global comment"/"Copy plan" labels before - // the ResizeObserver callback collapses them. - useLayoutEffect(() => { - if (isLoading && !isSharedSession) return; - - const el = planAreaRef.current; - if (!el) return; - const bucket = (w: number): ActionsLabelMode => - w >= 800 ? 'full' : w >= 680 ? 'short' : 'icon'; - setActionsLabelMode(bucket(el.getBoundingClientRect().width)); - const ro = new ResizeObserver(([entry]) => { - const next = bucket(entry.contentRect.width); - setActionsLabelMode((prev) => (prev === next ? prev : next)); - }); - ro.observe(el); - return () => ro.disconnect(); - }, [isLoading, isSharedSession]); - // Auto-save annotation drafts const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({ annotations: allAnnotations, codeAnnotations, globalAttachments, - isApiMode: isApiMode && !goalSetupMode, + isApiMode, isSharedSession, submitted: !!submitted, }); const handleRestoreDraft = React.useCallback(() => { - const { annotations: restored, codeAnnotations: restoredCode, globalAttachments: restoredGlobal } = restoreDraft(); - if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) { + const { + annotations: restored, + codeAnnotations: restoredCode, + globalAttachments: restoredGlobal, + } = restoreDraft(); + if ( + restored.length > 0 || + restoredCode.length > 0 || + restoredGlobal.length > 0 + ) { setAnnotations(restored); setCodeAnnotations(restoredCode); if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); // Apply highlights to DOM after a tick setTimeout(() => { - viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); + viewerRef.current?.applySharedAnnotations( + restored.filter((a) => !a.diffContext), + ); }, 100); } }, [restoreDraft]); // Fetch available agents for OpenCode (for validation on approve) - const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin); + const { + agents: availableAgents, + validateAgent, + getAgentWarning, + } = useAgents(origin); // Apply shared annotations to DOM after they're loaded useEffect(() => { @@ -987,7 +928,9 @@ const App: React.FC = () => { const timer = setTimeout(() => { // Clear existing highlights first (important when loading new share URL) viewerRef.current?.clearAllHighlights(); - viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext)); + viewerRef.current?.applySharedAnnotations( + pendingSharedAnnotations.filter((a) => !a.diffContext), + ); clearPendingSharedAnnotations(); // `clearAllHighlights` wiped live external SSE highlights too; // tell the external-highlight bookkeeper to re-apply them. @@ -995,11 +938,15 @@ const App: React.FC = () => { }, 100); return () => clearTimeout(timer); } - }, [pendingSharedAnnotations, clearPendingSharedAnnotations, resetExternalHighlights]); + }, [ + pendingSharedAnnotations, + clearPendingSharedAnnotations, + resetExternalHighlights, + ]); const handleTaterModeChange = useCallback((enabled: boolean) => { setTaterMode(enabled); - storage.setItem('plannotator-tater-mode', String(enabled)); + storage.setItem("plannotator-tater-mode", String(enabled)); }, []); const handleEditorModeChange = (mode: EditorMode) => { @@ -1021,158 +968,138 @@ const App: React.FC = () => { if (isLoadingShared) return; // Wait for share check to complete if (isSharedSession) return; // Already loaded from share - fetch('/api/plan') - .then(res => { - if (!res.ok) throw new Error('Not in API mode'); + fetch("/api/plan") + .then((res) => { + if (!res.ok) throw new Error("Not in API mode"); return res.json(); }) - .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; convertHtml?: boolean; 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 }; recentMessages?: PickerMessage[] }) => { - // Initialize config store with server-provided values (config file > cookie > default) - configStore.init(data.serverConfig); - // Session-level force-markdown preference (--markdown); threaded into folder/linked - // /api/doc requests so on-demand HTML files convert too. - setConvertHtml(data.convertHtml ?? false); - 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) { - setGoalSetupBundle(data.goalSetup); - setMarkdown(''); - setSharingEnabled(false); - } else if (data.mode === 'archive') { - // Archive mode: show first archived plan or clear demo content - setMarkdown(data.plan || ''); - if (data.archivePlans) archive.init(data.archivePlans); - archive.fetchPlans(); - setSharingEnabled(false); - sidebar.open('archive'); - } else if (data.renderAs === 'html' && data.rawHtml) { - setRenderAs('html'); - setRawHtml(data.rawHtml); - setMarkdown(''); - } else if (data.mode === 'annotate-folder') { - // Folder annotation mode: clear demo content, let user pick a file - setMarkdown(''); - } else if (data.plan) { - setMarkdown(data.plan); - } - setIsApiMode(true); - if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { - setAnnotateMode(true); - setGate(data.gate ?? false); - } - if (data.mode === 'annotate-folder') { - sidebar.open('files'); - } - if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { - setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file'); - } - if (data.mode === 'annotate-last' && data.recentMessages && data.recentMessages.length > 0) { - messageStateCacheRef.current = new Map(); - setCachedMessageAnnotationCounts(new Map()); - setRecentMessages(data.recentMessages); - setSelectedMessageId(data.recentMessages[0].messageId); - } else { - messageStateCacheRef.current = new Map(); - setCachedMessageAnnotationCounts(new Map()); - setRecentMessages([]); - setSelectedMessageId(null); - } - setSourceInfo(data.sourceInfo ?? undefined); - setSourceConverted(!!data.sourceConverted); - if (data.filePath) { - setImageBaseDir(data.mode === 'annotate-folder' ? data.filePath : data.filePath.replace(/\/[^/]+$/, '')); - if (data.mode === 'annotate') { - setSourceFilePath(data.filePath); + .then( + (data: { + plan: string; + origin?: Origin; + mode?: "annotate" | "annotate-last" | "annotate-folder" | "archive"; + 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); + // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable + setGitUser(data.serverConfig?.gitUser); + if (data.mode === "archive") { + // Archive mode: show first archived plan or clear demo content + setMarkdown(data.plan || ""); + if (data.archivePlans) archive.init(data.archivePlans); + archive.fetchPlans(); + setSharingEnabled(false); + sidebar.open("archive"); + } else if (data.renderAs === "html" && data.rawHtml) { + setRenderAs("html"); + setRawHtml(data.rawHtml); + setMarkdown(""); + } else if (data.mode === "annotate-folder") { + // Folder annotation mode: clear demo content, let user pick a file + setMarkdown(""); + } else if (data.plan) { + setMarkdown(data.plan); } - } - if (data.sharingEnabled !== undefined) { - setSharingEnabled(data.sharingEnabled); - } - if (data.shareBaseUrl) { - setShareBaseUrl(data.shareBaseUrl); - } - if (data.pasteApiUrl) { - setPasteApiUrl(data.pasteApiUrl); - } - if (data.repoInfo) { - setRepoInfo(data.repoInfo); - } - if (data.projectRoot) { - setProjectRoot(data.projectRoot); - } - // Capture plan version history data - if (data.previousPlan !== undefined) { - setPreviousPlan(data.previousPlan); - } - if (data.versionInfo) { - setVersionInfo(data.versionInfo); - } - if (data.origin) { - setOrigin(data.origin); - // For Claude Code, check if user needs to configure permission mode - if (data.origin === 'claude-code' && data.mode !== 'goal-setup' && needsPermissionModeSetup()) { - setShowPermissionModeSetup(true); + setIsApiMode(true); + if ( + data.mode === "annotate" || + data.mode === "annotate-last" || + data.mode === "annotate-folder" + ) { + setAnnotateMode(true); + setGate(data.gate ?? false); } - // Load saved permission mode preference - setPermissionMode(getPermissionModeSettings().mode); - } - if (data.isWSL) { - setIsWSL(true); - } - }) + if (data.mode === "annotate-folder") { + sidebar.open("files"); + } + if (data.mode && data.mode !== "archive") { + setAnnotateSource( + data.mode === "annotate-last" + ? "message" + : data.mode === "annotate-folder" + ? "folder" + : "file", + ); + } + setSourceInfo(data.sourceInfo ?? undefined); + setSourceConverted(!!data.sourceConverted); + if (data.filePath) { + setImageBaseDir( + data.mode === "annotate-folder" + ? data.filePath + : data.filePath.replace(/\/[^/]+$/, ""), + ); + if (data.mode === "annotate") { + setSourceFilePath(data.filePath); + } + } + if (data.sharingEnabled !== undefined) { + setSharingEnabled(data.sharingEnabled); + } + if (data.shareBaseUrl) { + setShareBaseUrl(data.shareBaseUrl); + } + if (data.pasteApiUrl) { + setPasteApiUrl(data.pasteApiUrl); + } + if (data.repoInfo) { + setRepoInfo(data.repoInfo); + } + if (data.projectRoot) { + setProjectRoot(data.projectRoot); + } + // Capture plan version history data + if (data.previousPlan !== undefined) { + setPreviousPlan(data.previousPlan); + } + if (data.versionInfo) { + setVersionInfo(data.versionInfo); + } + if (data.toolName) { + setPendingToolName(data.toolName); + } + if (data.origin) { + setOrigin(data.origin); + // For Claude Code, check if user needs to configure permission mode + if (data.origin === "claude-code" && needsPermissionModeSetup()) { + setShowPermissionModeSetup(true); + } + // Load saved permission mode preference + const savedPermissionMode = getPermissionModeSettings().mode; + setPermissionMode(savedPermissionMode); + } + if (data.isWSL) { + setIsWSL(true); + } + }, + ) .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({}); @@ -1185,7 +1112,14 @@ const App: React.FC = () => { }, [markdown]); useEffect(() => { - if (!isApiMode || !markdown || isSharedSession || annotateMode || archive.archiveMode) return; + if ( + !isApiMode || + !markdown || + isSharedSession || + annotateMode || + archive.archiveMode + ) + return; if (autoSaveAttempted.current) return; const body: { obsidian?: object; bear?: object; octarine?: object } = {}; @@ -1197,12 +1131,17 @@ const App: React.FC = () => { if (vaultPath) { body.obsidian = { vaultPath, - folder: obsSettings.folder || 'plannotator', + folder: obsSettings.folder || "plannotator", plan: markdown, - ...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }), - ...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }), + ...(obsSettings.filenameFormat && { + filenameFormat: obsSettings.filenameFormat, + }), + ...(obsSettings.filenameSeparator && + obsSettings.filenameSeparator !== "space" && { + filenameSeparator: obsSettings.filenameSeparator, + }), }; - targets.push('Obsidian'); + targets.push("Obsidian"); } } @@ -1213,7 +1152,7 @@ const App: React.FC = () => { customTags: bearSettings.customTags, tagPosition: bearSettings.tagPosition, }; - targets.push('Bear'); + targets.push("Bear"); } const octSettings = getOctarineSettings(); @@ -1221,40 +1160,46 @@ const App: React.FC = () => { body.octarine = { plan: markdown, workspace: octSettings.workspace, - folder: octSettings.folder || 'plannotator', + folder: octSettings.folder || "plannotator", }; - targets.push('Octarine'); + targets.push("Octarine"); } if (targets.length === 0) return; autoSaveAttempted.current = true; - const autoSavePromise = fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const autoSavePromise = fetch("/api/save-notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) - .then(res => res.json()) - .then(data => { + .then((res) => res.json()) + .then((data) => { const results: NoteAutoSaveResults = { - ...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}), + ...(body.obsidian + ? { obsidian: Boolean(data.results?.obsidian?.success) } + : {}), ...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}), - ...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}), + ...(body.octarine + ? { octarine: Boolean(data.results?.octarine?.success) } + : {}), }; autoSaveResultsRef.current = results; - const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success); + const failed = targets.filter( + (t) => !data.results?.[t.toLowerCase()]?.success, + ); if (failed.length === 0) { - toast.success(`Auto-saved to ${targets.join(' & ')}`); + toast.success(`Auto-saved to ${targets.join(" & ")}`); } else { - toast.error(`Auto-save failed for ${failed.join(' & ')}`); + toast.error(`Auto-save failed for ${failed.join(" & ")}`); } return results; }) .catch(() => { autoSaveResultsRef.current = {}; - toast.error('Auto-save failed'); + toast.error("Auto-save failed"); return {}; }); autoSavePromiseRef.current = autoSavePromise; @@ -1267,12 +1212,15 @@ const App: React.FC = () => { if (!items) return; for (const item of items) { - if (item.type.startsWith('image/')) { + if (item.type.startsWith("image/")) { e.preventDefault(); const file = item.getAsFile(); if (file) { // Derive name before showing annotator so user sees it immediately - const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name)); + const initialName = deriveImageName( + file.name, + globalAttachments.map((g) => g.name), + ); const blobUrl = URL.createObjectURL(file); setPendingPasteImage({ file, blobUrl, initialName }); } @@ -1281,25 +1229,32 @@ const App: React.FC = () => { } }; - document.addEventListener('paste', handlePaste); - return () => document.removeEventListener('paste', handlePaste); + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); }, [globalAttachments]); // Handle paste annotator accept — name comes from ImageAnnotator - const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { + const handlePasteAnnotatorAccept = async ( + blob: Blob, + hasDrawings: boolean, + name: string, + ) => { if (!pendingPasteImage) return; try { const formData = new FormData(); const fileToUpload = hasDrawings - ? new File([blob], 'annotated.png', { type: 'image/png' }) + ? new File([blob], "annotated.png", { type: "image/png" }) : pendingPasteImage.file; - formData.append('file', fileToUpload); + formData.append("file", fileToUpload); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); if (res.ok) { const data = await res.json(); - setGlobalAttachments(prev => [...prev, { path: data.path, name }]); + setGlobalAttachments((prev) => [...prev, { path: data.path, name }]); } } catch { // Upload failed silently @@ -1317,50 +1272,81 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); const bearSettings = getBearSettings(); const octarineSettings = getOctarineSettings(); const planSaveSettings = getPlanSaveSettings(); - const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current - ? await autoSavePromiseRef.current - : autoSaveResultsRef.current; - - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; + const autoSaveResults = + bearSettings.autoSave && autoSavePromiseRef.current + ? await autoSavePromiseRef.current + : autoSaveResultsRef.current; + + const effectiveMode = override.permissionMode ?? permissionMode; + const shouldUseNativeClear = + origin === "claude-code" && + pendingToolName === "ExitPlanMode" && + (override.deferToNativeForClear || effectiveMode === "deferNative"); + if (shouldUseNativeClear && !clearContextConsentResolvedRef.current) { + // Native clear writes to the user's Claude Code settings, so it needs + // a one-time consent. Try to enable silently; if that fails, PAUSE the + // approval and surface the consent banner instead of proceeding. The + // banner's Enable/Skip buttons resolve consent and resume the approval, + // so the prompt is shown at most once rather than on every Approve. + let enabled = false; + try { + const response = await fetch("/api/enable-clear-context", { + method: "POST", + }); + enabled = response.ok; + } catch { + enabled = false; + } + if (enabled) { + clearContextConsentResolvedRef.current = true; + setShowClearContextBanner(false); + } else { + setPendingApprovalOverride(override); + setShowClearContextBanner(true); + setIsSubmitting(false); + return; + } } const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); - if (effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - // Include plan save settings - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; + const body = buildApprovalRequestBody({ + origin, + permissionMode, + override, + effectiveAgent, + planSaveSettings, + toolName: pendingToolName, + }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); if (obsidianSettings.enabled && effectiveVaultPath) { body.obsidian = { vaultPath: effectiveVaultPath, - folder: obsidianSettings.folder || 'plannotator', + folder: obsidianSettings.folder || "plannotator", plan: markdown, - ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), - ...(obsidianSettings.filenameSeparator && obsidianSettings.filenameSeparator !== 'space' && { filenameSeparator: obsidianSettings.filenameSeparator }), + ...(obsidianSettings.filenameFormat && { + filenameFormat: obsidianSettings.filenameFormat, + }), + ...(obsidianSettings.filenameSeparator && + obsidianSettings.filenameSeparator !== "space" && { + filenameSeparator: obsidianSettings.filenameSeparator, + }), }; } // Bear creates a new note each time, so don't send it again on approve // if the arrival auto-save already succeeded. - if (bearSettings.enabled && !(bearSettings.autoSave && autoSaveResults.bear)) { + if ( + bearSettings.enabled && + !(bearSettings.autoSave && autoSaveResults.bear) + ) { body.bear = { plan: markdown, customTags: bearSettings.customTags, @@ -1372,24 +1358,30 @@ const App: React.FC = () => { body.octarine = { plan: markdown, workspace: octarineSettings.workspace, - folder: octarineSettings.folder || 'plannotator', + folder: octarineSettings.folder || "plannotator", }; } // Include annotations as feedback if any exist (for OpenCode "approve with notes") - const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 - ); - if (allAnnotations.length > 0 || codeAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) { - body.feedback = messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput; + const hasDocAnnotations = Array.from( + linkedDocHook.getDocAnnotations().values(), + ).some((d) => d.annotations.length > 0 || d.globalAttachments.length > 0); + if ( + allAnnotations.length > 0 || + codeAnnotations.length > 0 || + globalAttachments.length > 0 || + hasDocAnnotations || + editorAnnotations.length > 0 + ) { + body.feedback = annotationsOutput; } - await fetch('/api/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - setSubmitted('approved'); + setSubmitted("approved"); } catch { setIsSubmitting(false); } @@ -1399,54 +1391,85 @@ const App: React.FC = () => { setIsSubmitting(true); try { const planSaveSettings = getPlanSaveSettings(); - await fetch('/api/deny', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/deny", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - feedback: messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput, + feedback: annotationsOutput, planSave: { enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + ...(planSaveSettings.customPath && { + customPath: planSaveSettings.customPath, + }), }, - }) + }), }); - setSubmitted('denied'); + setSubmitted("denied"); } catch { setIsSubmitting(false); } }; + const approveWithClaudeCodeWarning = useCallback( + (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if ( + origin === "claude-code" && + (allAnnotations.length > 0 || codeAnnotations.length > 0) + ) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, + [allAnnotations.length, codeAnnotations.length, origin, handleApprove], + ); + + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== "claude-code") return []; + if (pendingToolName === "ExitPlanMode") { + return [ + { + id: "approve-bypass-native-clear", + label: "Approve + Bypass + Clear Context (native)", + description: + "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => + approveWithClaudeCodeWarning({ + permissionMode: "bypassPermissions", + deferToNativeForClear: true, + }), + }, + ]; + } + return []; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); try { - const feedback = messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput; - const scopedSelectedMessageId = messageMultiSelectMode - ? annotatedMessageIds.length === 1 ? annotatedMessageIds[0] : undefined - : selectedMessageId ?? undefined; - await fetch('/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - feedback, + feedback: annotationsOutput, annotations: allAnnotations, codeAnnotations, - ...(scopedSelectedMessageId ? { selectedMessageId: scopedSelectedMessageId } : {}), - ...(messageMultiSelectMode && annotatedMessageIds.length > 1 ? { feedbackScope: 'messages' } : {}), }), }); - setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay + setSubmitted("denied"); // reuse 'denied' state for "feedback sent" overlay } catch { setIsSubmitting(false); } }; - // Annotate gate-mode handler — approves the artifact without feedback + // Annotate gate-mode handler — approves the artifact without feedback (#570) const handleAnnotateApprove = async () => { setIsSubmitting(true); try { - await fetch('/api/approve', { method: 'POST' }); - setSubmitted('approved'); + await fetch("/api/approve", { method: "POST" }); + setSubmitted("approved"); } catch { setIsSubmitting(false); } @@ -1456,29 +1479,11 @@ const App: React.FC = () => { const handleAnnotateExit = useCallback(async () => { setIsExiting(true); try { - const res = await fetch('/api/exit', { method: 'POST' }); + const res = await fetch("/api/exit", { method: "POST" }); if (res.ok) { - setSubmitted('exited'); + setSubmitted("exited"); } else { - throw new Error('Failed to exit'); - } - } catch { - setIsExiting(false); - } - }, []); - - const handleGoalSetupSubmit = useCallback(() => { - goalSetupSurfaceRef.current?.submit(); - }, []); - - const handleGoalSetupExit = useCallback(async () => { - setIsExiting(true); - try { - const res = await fetch('/api/exit', { method: 'POST' }); - if (res.ok) { - setSubmitted('exited'); - } else { - throw new Error('Failed to exit'); + throw new Error("Failed to exit"); } } catch { setIsExiting(false); @@ -1489,21 +1494,27 @@ const App: React.FC = () => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle Cmd/Ctrl+Enter - if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; - - const target = e.target as HTMLElement | null; - const tag = target?.tagName; - const isTextField = tag === 'INPUT' || tag === 'TEXTAREA' || Boolean(target?.isContentEditable); + if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return; - // Let active confirmation dialogs own Cmd/Ctrl+Enter and Escape. - if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return; + // Don't intercept if typing in an input/textarea + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; // Don't intercept if any modal is open - if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if ( + showExport || + showImport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; // Don't intercept if already submitted, submitting, or exiting - if (submitted || isSubmitting || isExiting || goalSetupAction.isSubmitting) return; + if (submitted || isSubmitting || isExiting) return; // Don't intercept in demo/share mode (no API) if (!isApiMode) return; @@ -1511,17 +1522,6 @@ const App: React.FC = () => { // Don't submit while viewing a linked doc if (linkedDocHook.isActive) return; - if (goalSetupMode) { - if (document.querySelector('[data-comment-popover="true"]')) return; - if (isTextField && !target?.closest('.goal-shell')) return; - e.preventDefault(); - if (goalSetupAction.canSubmit) goalSetupSurfaceRef.current?.submit(); - return; - } - - // Don't intercept if typing in an input/textarea outside goal setup. - if (isTextField) return; - e.preventDefault(); // Annotate mode: gate-enabled + no annotations → approve (empty stdout). @@ -1538,11 +1538,16 @@ const App: React.FC = () => { // No annotations → Approve, otherwise → Send Feedback const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if ( + allAnnotations.length === 0 && + codeAnnotations.length === 0 && + editorAnnotations.length === 0 && + !hasDocAnnotations + ) { // Check if agent exists for OpenCode users - if (origin === 'opencode') { + if (origin === "opencode") { const warning = getAgentWarning(); if (warning) { setAgentWarningMessage(warning); @@ -1556,18 +1561,34 @@ const App: React.FC = () => { } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isSubmitting, isExiting, goalSetupAction.isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, - gate, hasAnyAnnotations, goalSetupMode, goalSetupAction.canSubmit, - origin, getAgentWarning, + showExport, + showImport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, + isSubmitting, + isExiting, + isApiMode, + linkedDocHook.isActive, + annotations.length, + codeAnnotations.length, + externalAnnotations.length, + annotateMode, + gate, + hasAnyAnnotations, + origin, + getAgentWarning, ]); const handleAddAnnotation = (ann: Annotation) => { - setAnnotations(prev => [...prev, ann]); + setAnnotations((prev) => [...prev, ann]); setSelectedAnnotationId(ann.id); setSelectedCodeAnnotationId(null); if (wideModeType === null) { @@ -1576,60 +1597,77 @@ const App: React.FC = () => { }; // Keep selection behavior explicit across mobile/wide-mode transitions. - const handleSelectAnnotation = React.useCallback((id: string | null) => { - setSelectedAnnotationId(id); - if (id) setSelectedCodeAnnotationId(null); - if (id && isMobile && wideModeType === null) setIsPanelOpen(true); - }, [isMobile, wideModeType]); - - const handleAddCodeAnnotation = React.useCallback((input: CodeFileAnnotationInput) => { - const annotation: CodeAnnotation = { - id: generateId('code-ann'), - type: 'comment', - scope: 'line', - filePath: input.filePath, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - side: 'new', - text: input.text, - images: input.images, - originalCode: input.originalCode, - createdAt: Date.now(), - author: configStore.get('displayName') || undefined, - }; - setCodeAnnotations(prev => [...prev, annotation]); - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(annotation.id); - if (wideModeType === null) { - setIsPanelOpen(true); - } - }, [wideModeType]); + const handleSelectAnnotation = React.useCallback( + (id: string | null) => { + setSelectedAnnotationId(id); + if (id) setSelectedCodeAnnotationId(null); + if (id && isMobile && wideModeType === null) setIsPanelOpen(true); + }, + [isMobile, wideModeType], + ); + + const handleAddCodeAnnotation = React.useCallback( + (input: CodeFileAnnotationInput) => { + const annotation: CodeAnnotation = { + id: generateId("code-ann"), + type: "comment", + scope: "line", + filePath: input.filePath, + lineStart: input.lineStart, + lineEnd: input.lineEnd, + side: "new", + text: input.text, + images: input.images, + originalCode: input.originalCode, + createdAt: Date.now(), + author: configStore.get("displayName") || undefined, + }; + setCodeAnnotations((prev) => [...prev, annotation]); + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(annotation.id); + if (wideModeType === null) { + setIsPanelOpen(true); + } + }, + [wideModeType], + ); // The code popout is full-viewport modal — the annotation panel is behind it. // This handler only fires when the popout is closed (sidebar visible), so // reopening the file via codeFilePopout.open() is the correct behavior. - const handleSelectCodeAnnotation = React.useCallback((id: string) => { - const annotation = codeAnnotations.find(a => a.id === id); - if (!annotation) return; - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(id); - codeFilePopout.open(annotation.filePath); - if (isMobile && wideModeType === null) setIsPanelOpen(true); - }, [codeAnnotations, codeFilePopout.open, isMobile, wideModeType]); - - const handleDeleteCodeAnnotation = React.useCallback((id: string) => { - setCodeAnnotations(prev => prev.filter(a => a.id !== id)); - if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null); - }, [selectedCodeAnnotationId]); - - const handleEditCodeAnnotation = React.useCallback((id: string, updates: Partial) => { - setCodeAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a)); - }, []); + const handleSelectCodeAnnotation = React.useCallback( + (id: string) => { + const annotation = codeAnnotations.find((a) => a.id === id); + if (!annotation) return; + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(id); + codeFilePopout.open(annotation.filePath); + if (isMobile && wideModeType === null) setIsPanelOpen(true); + }, + [codeAnnotations, codeFilePopout.open, isMobile, wideModeType], + ); + + const handleDeleteCodeAnnotation = React.useCallback( + (id: string) => { + setCodeAnnotations((prev) => prev.filter((a) => a.id !== id)); + if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null); + }, + [selectedCodeAnnotationId], + ); + + const handleEditCodeAnnotation = React.useCallback( + (id: string, updates: Partial) => { + setCodeAnnotations((prev) => + prev.map((a) => (a.id === id ? { ...a, ...updates } : a)), + ); + }, + [], + ); // Core annotation removal — highlight cleanup + state filter + selection clear const removeAnnotation = (id: string) => { viewerRef.current?.removeHighlight(id); - setAnnotations(prev => prev.filter(a => a.id !== id)); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); if (selectedAnnotationId === id) setSelectedAnnotationId(null); }; @@ -1642,17 +1680,17 @@ const App: React.FC = () => { }); const handleDeleteAnnotation = (id: string) => { - const ann = allAnnotations.find(a => a.id === id); + const ann = allAnnotations.find((a) => a.id === id); // External annotations (live in SSE hook) route to the SSE hook, not local state. // Check membership by ID — source alone is insufficient because share-imported // and draft-restored annotations also carry source but live in local state. - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + if (ann?.source && externalAnnotations.some((e) => e.id === id)) { deleteExternalAnnotation(id); if (selectedAnnotationId === id) setSelectedAnnotationId(null); return; } // If this is a checkbox annotation, revert the visual override - if (id.startsWith('ann-checkbox-')) { + if (id.startsWith("ann-checkbox-")) { if (ann) { checkbox.revertOverride(ann.blockId); } @@ -1661,65 +1699,68 @@ const App: React.FC = () => { }; const handleEditAnnotation = (id: string, updates: Partial) => { - const ann = allAnnotations.find(a => a.id === id); - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + const ann = allAnnotations.find((a) => a.id === id); + if (ann?.source && externalAnnotations.some((e) => e.id === id)) { updateExternalAnnotation(id, updates); return; } - setAnnotations(prev => prev.map(a => - a.id === id ? { ...a, ...updates } : a - )); + setAnnotations((prev) => + prev.map((a) => (a.id === id ? { ...a, ...updates } : a)), + ); }; - const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { - setAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); - setCodeAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); - }, []); + const handleIdentityChange = useCallback( + (oldIdentity: string, newIdentity: string) => { + setAnnotations((prev) => + prev.map((ann) => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann, + ), + ); + setCodeAnnotations((prev) => + prev.map((ann) => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann, + ), + ); + }, + [], + ); const handleAddGlobalAttachment = (image: ImageAttachment) => { - setGlobalAttachments(prev => [...prev, image]); + setGlobalAttachments((prev) => [...prev, image]); }; const handleRemoveGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => prev.filter(p => p.path !== path)); + setGlobalAttachments((prev) => prev.filter((p) => p.path !== path)); }; - const handleTocNavigate = (blockId: string) => { // Navigation handled by TableOfContents component // This is just a placeholder for future custom logic }; - const buildFullAnnotationsOutput = React.useCallback((): string => { - if (messageMultiSelectMode) { - let output = exportMessageAnnotations(buildMessageAnnotationEntries()); - if (editorAnnotations.length > 0) { - output += `\n\n${exportEditorAnnotations(editorAnnotations)}`; - } - return output; - } - return ''; - }, [messageMultiSelectMode, buildMessageAnnotationEntries, editorAnnotations]); - const annotationsOutput = useMemo(() => { const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0; + const hasPlanAnnotations = + allAnnotations.length > 0 || globalAttachments.length > 0; const hasEditorAnnotations = editorAnnotations.length > 0; const hasCodeAnnotations = codeAnnotations.length > 0; - if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations && !hasCodeAnnotations) { - return 'User reviewed the document and has no feedback.'; + if ( + !hasPlanAnnotations && + !hasDocAnnotations && + !hasEditorAnnotations && + !hasCodeAnnotations + ) { + return "User reviewed the document and has no feedback."; } + // Derive the conversion flag for the currently-displayed document: + // when viewing a linked doc, use that doc's isConverted; otherwise use the root flag. const activeConverted = linkedDocHook.isActive - ? (docAnnotations.get(linkedDocHook.filepath ?? '')?.isConverted ?? false) + ? (docAnnotations.get(linkedDocHook.filepath ?? "")?.isConverted ?? false) : sourceConverted; let output = hasPlanAnnotations @@ -1727,17 +1768,30 @@ const App: React.FC = () => { blocks, allAnnotations, globalAttachments, - annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', - annotateSource ?? 'plan', + annotateSource === "message" + ? "Message Feedback" + : annotateSource === "folder" + ? "Folder Feedback" + : annotateSource === "file" + ? "File Feedback" + : "Plan Feedback", + annotateSource ?? "plan", { sourceConverted: activeConverted }, ) - : ''; + : ""; if (hasDocAnnotations) { - const enriched: Map = new Map(docAnnotations); + // Parse blocks for each linked doc's cached markdown so the exporter + // can attach source line numbers per annotation. + const enriched: Map = new Map( + docAnnotations, + ); for (const [filepath, entry] of enriched) { if (entry.markdown) { - enriched.set(filepath, { ...entry, blocks: parseMarkdownToBlocks(entry.markdown) }); + enriched.set(filepath, { + ...entry, + blocks: parseMarkdownToBlocks(entry.markdown), + }); } } output += exportLinkedDocAnnotations(enriched); @@ -1752,220 +1806,96 @@ 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; - // renderAs now tracks the active file (plan, linked doc, or folder file), so the AI - // sees the current surface's mode — raw HTML for an .html file, markdown otherwise. - const aiRenderAs = 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, + blocks, + allAnnotations, + globalAttachments, + linkedDocHook.getDocAnnotations, + editorAnnotations, + codeAnnotations, + sourceConverted, + annotateSource, + linkedDocHook.isActive, + linkedDocHook.filepath, ]); - 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. // Fix: either disable callbacks when codeAnnotations exist, or include annotationsOutput in the payload. const callbackConfig = React.useMemo(() => getCallbackConfig(), []); - const callCallback = React.useCallback(async (action: CallbackAction) => { - if (!callbackConfig || isSubmitting || (!shareUrl && !shortShareUrl)) return; - setIsSubmitting(true); - try { - const result = await executeCallback(action, callbackConfig, shortShareUrl || shareUrl); - if (result) { - if (result.type === 'success') { - toast.success(result.message); - setSubmitted(action === CallbackAction.Approve ? 'approved' : 'denied'); - } else { - toast.error(result.message); + const callCallback = React.useCallback( + async (action: CallbackAction) => { + if (!callbackConfig || isSubmitting || (!shareUrl && !shortShareUrl)) + return; + setIsSubmitting(true); + try { + const result = await executeCallback( + action, + callbackConfig, + shortShareUrl || shareUrl, + ); + if (result) { + if (result.type === "success") { + toast.success(result.message); + setSubmitted( + action === CallbackAction.Approve ? "approved" : "denied", + ); + } else { + toast.error(result.message); + } } + } finally { + setIsSubmitting(false); } - } finally { - setIsSubmitting(false); - } - }, [callbackConfig, isSubmitting, shareUrl, shortShareUrl]); + }, + [callbackConfig, isSubmitting, shareUrl, shortShareUrl], + ); - const handleCallbackApprove = React.useCallback(() => callCallback(CallbackAction.Approve), [callCallback]); - const handleCallbackFeedback = React.useCallback(() => callCallback(CallbackAction.Feedback), [callCallback]); + const handleCallbackApprove = React.useCallback( + () => callCallback(CallbackAction.Approve), + [callCallback], + ); + const handleCallbackFeedback = React.useCallback( + () => callCallback(CallbackAction.Feedback), + [callCallback], + ); // Quick-save handlers for export dropdown and keyboard shortcut const handleDownloadAnnotations = () => { - const output = messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput; - const blob = new Blob([output], { type: 'text/plain' }); + const blob = new Blob([annotationsOutput], { type: "text/plain" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = 'annotations.md'; + a.download = "annotations.md"; a.click(); URL.revokeObjectURL(url); - toast.success('Downloaded annotations'); + toast.success("Downloaded annotations"); }; - const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => { + const handleQuickSaveToNotes = async ( + target: "obsidian" | "bear" | "octarine", + ) => { const body: { obsidian?: object; bear?: object; octarine?: object } = {}; - if (target === 'obsidian') { + if (target === "obsidian") { const s = getObsidianSettings(); const vaultPath = getEffectiveVaultPath(s); if (vaultPath) { body.obsidian = { vaultPath, - folder: s.folder || 'plannotator', + folder: s.folder || "plannotator", plan: markdown, ...(s.filenameFormat && { filenameFormat: s.filenameFormat }), - ...(s.filenameSeparator && s.filenameSeparator !== 'space' && { filenameSeparator: s.filenameSeparator }), + ...(s.filenameSeparator && + s.filenameSeparator !== "space" && { + filenameSeparator: s.filenameSeparator, + }), }; } } - if (target === 'bear') { + if (target === "bear") { const bs = getBearSettings(); body.bear = { plan: markdown, @@ -1973,20 +1903,25 @@ const App: React.FC = () => { tagPosition: bs.tagPosition, }; } - if (target === 'octarine') { + if (target === "octarine") { const os = getOctarineSettings(); body.octarine = { plan: markdown, workspace: os.workspace, - folder: os.folder || 'plannotator', + folder: os.folder || "plannotator", }; } - const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine'; + const targetName = + target === "obsidian" + ? "Obsidian" + : target === "bear" + ? "Bear" + : "Octarine"; try { - const res = await fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/save-notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); @@ -1994,10 +1929,10 @@ const App: React.FC = () => { if (result?.success) { toast.success(`Saved to ${targetName}`); } else { - toast.error(result?.error || 'Save failed'); + toast.error(result?.error || "Save failed"); } } catch { - toast.error('Save failed'); + toast.error("Save failed"); } }; @@ -2009,9 +1944,9 @@ const App: React.FC = () => { const payload = buildPlanAgentInstructions(window.location.origin); try { await navigator.clipboard.writeText(payload); - toast.success('Agent instructions copied'); + toast.success("Agent instructions copied"); } catch { - toast.error('Failed to copy'); + toast.error("Failed to copy"); } }; @@ -2020,22 +1955,30 @@ const App: React.FC = () => { if (!url) return; try { await navigator.clipboard.writeText(url); - toast.success('Share link copied'); + toast.success("Share link copied"); } catch { - toast.error('Failed to copy'); + toast.error("Failed to copy"); } }; // Cmd/Ctrl+S keyboard shortcut — save to default notes app useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { - if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return; + if (e.key !== "s" || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if ( + showExport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; if (submitted || !isApiMode) return; @@ -2046,38 +1989,54 @@ const App: React.FC = () => { const bearOk = getBearSettings().enabled; const octOk = isOctarineConfigured(); - if (defaultApp === 'download') { + if (defaultApp === "download") { handleDownloadAnnotations(); - } else if (defaultApp === 'obsidian' && obsOk) { - handleQuickSaveToNotes('obsidian'); - } else if (defaultApp === 'bear' && bearOk) { - handleQuickSaveToNotes('bear'); - } else if (defaultApp === 'octarine' && octOk) { - handleQuickSaveToNotes('octarine'); + } else if (defaultApp === "obsidian" && obsOk) { + handleQuickSaveToNotes("obsidian"); + } else if (defaultApp === "bear" && bearOk) { + handleQuickSaveToNotes("bear"); + } else if (defaultApp === "octarine" && octOk) { + handleQuickSaveToNotes("octarine"); } else { - setInitialExportTab('notes'); + setInitialExportTab("notes"); setShowExport(true); } }; - window.addEventListener('keydown', handleSaveShortcut); - return () => window.removeEventListener('keydown', handleSaveShortcut); + window.addEventListener("keydown", handleSaveShortcut); + return () => window.removeEventListener("keydown", handleSaveShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isApiMode, markdown, annotationsOutput, + showExport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, + isApiMode, + markdown, + annotationsOutput, ]); // Cmd/Ctrl+P keyboard shortcut — print plan useEffect(() => { const handlePrintShortcut = (e: KeyboardEvent) => { - if (e.key !== 'p' || !(e.metaKey || e.ctrlKey)) return; + if (e.key !== "p" || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if ( + showExport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; if (submitted) return; @@ -2085,11 +2044,17 @@ const App: React.FC = () => { window.print(); }; - window.addEventListener('keydown', handlePrintShortcut); - return () => window.removeEventListener('keydown', handlePrintShortcut); + window.addEventListener("keydown", handlePrintShortcut); + return () => window.removeEventListener("keydown", handlePrintShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, submitted, + showExport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, ]); const agentName = useMemo(() => getAgentName(origin), [origin]); @@ -2126,7 +2091,7 @@ const App: React.FC = () => { const handleHeaderAnnotateExit = useCallback(() => { if (hasAnyAnnotations) { - setExitWarningAction('close'); + setExitWarningAction("close"); setShowExitWarning(true); } else { headerHandlersRef.current.handleAnnotateExit(); @@ -2137,9 +2102,14 @@ const App: React.FC = () => { const h = headerHandlersRef.current; const docAnnotations = h.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if ( + allAnnotations.length === 0 && + codeAnnotations.length === 0 && + editorAnnotations.length === 0 && + !hasDocAnnotations + ) { setShowFeedbackPrompt(true); } else { h.handleDeny(); @@ -2150,18 +2120,21 @@ const App: React.FC = () => { const h = headerHandlersRef.current; if (annotateMode) { if (hasAnyAnnotations) { - setExitWarningAction('approve'); + setExitWarningAction("approve"); setShowExitWarning(true); return; } h.handleAnnotateApprove(); return; } - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + if ( + origin === "claude-code" && + (allAnnotations.length > 0 || codeAnnotations.length > 0) + ) { setShowClaudeCodeWarning(true); return; } - if (origin === 'opencode') { + if (origin === "opencode") { const warning = h.getAgentWarning(); if (warning) { setAgentWarningMessage(warning); @@ -2170,726 +2143,873 @@ const App: React.FC = () => { } } h.handleApprove(); - }, [annotateMode, hasAnyAnnotations, origin, allAnnotations.length, codeAnnotations.length]); + }, [ + annotateMode, + hasAnyAnnotations, + origin, + allAnnotations.length, + codeAnnotations.length, + ]); - const handleHeaderAnnotateFeedback = useCallback(() => headerHandlersRef.current.handleAnnotateFeedback(), []); - const handleHeaderAnnotateApprove = useCallback(() => headerHandlersRef.current.handleAnnotateApprove(), []); - const handleHeaderDownloadAnnotations = useCallback(() => headerHandlersRef.current.handleDownloadAnnotations(), []); - const handleHeaderCopyAgentInstructions = useCallback(() => headerHandlersRef.current.handleCopyAgentInstructions(), []); - const handleHeaderCopyShareLink = useCallback(() => headerHandlersRef.current.handleCopyShareLink(), []); + const handleHeaderAnnotateFeedback = useCallback( + () => headerHandlersRef.current.handleAnnotateFeedback(), + [], + ); + const handleHeaderAnnotateApprove = useCallback( + () => headerHandlersRef.current.handleAnnotateApprove(), + [], + ); + const handleHeaderDownloadAnnotations = useCallback( + () => headerHandlersRef.current.handleDownloadAnnotations(), + [], + ); + const handleHeaderCopyAgentInstructions = useCallback( + () => headerHandlersRef.current.handleCopyAgentInstructions(), + [], + ); + const handleHeaderCopyShareLink = useCallback( + () => headerHandlersRef.current.handleCopyShareLink(), + [], + ); const handleOpenSettings = useCallback(() => setMobileSettingsOpen(true), []); - const handleCloseSettings = useCallback(() => setMobileSettingsOpen(false), []); - const handleOpenExport = useCallback(() => { setInitialExportTab(undefined); setShowExport(true); }, []); + const handleCloseSettings = useCallback( + () => setMobileSettingsOpen(false), + [], + ); + const handleOpenExport = useCallback(() => { + setInitialExportTab(undefined); + setShowExport(true); + }, []); const handlePrint = useCallback(() => window.print(), []); const handleOpenImport = useCallback(() => setShowImport(true), []); - const handleSaveToObsidian = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('obsidian'), []); - const handleSaveToOctarine = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('octarine'), []); - const handleSaveToBear = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('bear'), []); + const handleSaveToObsidian = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("obsidian"), + [], + ); + const handleSaveToOctarine = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("octarine"), + [], + ); + const handleSaveToBear = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("bear"), + [], + ); const planMaxWidth = useMemo(() => { - const widths: Record = { compact: 832, default: 1040, wide: 1280 }; + const widths: Record = { + compact: 832, + default: 1040, + wide: 1280, + }; return widths[uiPrefs.planWidth] ?? 832; }, [uiPrefs.planWidth]); - const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth; - const selectedAIProvider = aiProviders.find(provider => provider.id === aiConfig.providerId) ?? null; - // Only greet in a normal authoring context — not on a read-only shared session - // (a viewer would also be able to flip the owner's gridEnabled), nor over the - // goal-setup / permission-mode flows. Deferred (not marked seen) until then. - const shouldShowLookAndFeelAnnouncement = - showLookAndFeelAnnouncement && - !isSharedSession && - !goalSetupMode && - !showPermissionModeSetup; - const shouldShowPlanAIAnnouncement = - showPlanAIAnnouncement && - !shouldShowLookAndFeelAnnouncement && - canUseAI && - aiSessionEnabled && - isApiMode && - !isSharedSession && - !archive.archiveMode && - !goalSetupMode && - !showPermissionModeSetup && - !submitted; - - - if (isLoading && !isSharedSession) { - return ( - -
- - ); - } + const annotateReaderMaxWidth = + canUseWideMode && wideModeType === "wide" ? null : planMaxWidth; return ( - -
- setHtmlToolsHidden((v) => !v)} - isApiMode={isApiMode} - annotateMode={annotateMode} - archiveMode={archive.archiveMode} - goalSetupMode={goalSetupMode} - goalSetupCanSubmit={goalSetupAction.canSubmit} - goalSetupIsSubmitting={goalSetupAction.isSubmitting} - goalSetupSubmitLabel={goalSetupAction.submitLabel} - gate={gate} - isSharedSession={isSharedSession} - origin={origin} - isSubmitting={isSubmitting} - isExiting={isExiting} - 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} - canShareCurrentSession={canShareCurrentSession} - agentName={agentName} - availableAgents={availableAgents} - showAnnotationsWarning={allAnnotations.length > 0 || codeAnnotations.length > 0} - callbackConfig={callbackConfig} - taterMode={taterMode} - mobileSettingsOpen={mobileSettingsOpen} - gitUser={gitUser} - onCallbackFeedback={handleCallbackFeedback} - onCallbackApprove={handleCallbackApprove} - onAnnotateExit={handleHeaderAnnotateExit} - onGoalSetupExit={handleGoalSetupExit} - onGoalSetupSubmit={handleGoalSetupSubmit} - onAnnotateFeedback={handleHeaderAnnotateFeedback} - onAnnotateApprove={handleHeaderAnnotateApprove} - onFeedback={handleHeaderFeedback} - onApprove={handleHeaderApprove} - onAnnotationPanelToggle={handleAnnotationPanelToggle} - onAIChatToggle={handleAIChatToggle} - onArchiveCopy={archive.copy} - onArchiveDone={archive.done} - onTaterModeChange={handleTaterModeChange} - onIdentityChange={handleIdentityChange} - onUIPreferencesChange={setUiPrefs} - onOpenSettings={handleOpenSettings} - onCloseSettings={handleCloseSettings} - onOpenExport={handleOpenExport} - onCopyAgentInstructions={handleHeaderCopyAgentInstructions} - onDownloadAnnotations={handleHeaderDownloadAnnotations} - onPrint={handlePrint} - onCopyShareLink={handleHeaderCopyShareLink} - onOpenImport={handleOpenImport} - onSaveToObsidian={handleSaveToObsidian} - onSaveToBear={handleSaveToBear} - onSaveToOctarine={handleSaveToOctarine} - appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - updateInfo={updateInfo} - isWSL={isWSL} - agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode && !goalSetupMode} - obsidianConfigured={isObsidianConfigured()} - bearConfigured={getBearSettings().enabled} - octarineConfigured={isOctarineConfigured()} - /> - - {/* Linked document error banner */} - {linkedDocHook.error && ( -
- {linkedDocHook.error} - -
- )} - - {/* Main Content */} - -
- {/* Tater sprites — inside content wrapper so z-0 stacking context applies */} - {taterMode && } - {/* Left Sidebar: collapsed tab flags (when sidebar is closed) */} - {wideModeType === null && !sidebar.isOpen && !goalSetupMode && ( - 1} - showFilesTab={showFilesTab && !archive.archiveMode} - showMessagesTab={annotateSource === 'message' && recentMessages.length > 1} - hasMessageAnnotations={activeMessageAnnotationCounts.size > 0} - hasFileAnnotations={hasFileAnnotations} - className="hidden lg:flex absolute left-0 top-0 z-20" - /> - )} - - {/* Left Sidebar: open state (TOC or Version Browser) */} - {sidebar.isOpen && !goalSetupMode && ( -
- { - toggleSidebarTab(tab); - if (tab === 'archive' && !archive.archiveMode) archive.fetchPlans(); - }} - onClose={sidebar.close} - width={`var(--toc-w, ${tocResize.width}px)`} - blocks={blocks} - annotations={annotations} - activeSection={activeSection} - onTocNavigate={handleTocNavigate} - linkedDocFilepath={linkedDocHook.filepath} - onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined} - backLabel={backLabel} - showFilesTab={showFilesTab && !archive.archiveMode} - fileAnnotationCounts={fileAnnotationCounts} - highlightedFiles={highlightedFiles} - fileBrowser={fileBrowser} - onFilesSelectFile={handleFileBrowserSelect} - onFilesFetchAll={() => fileBrowser.fetchAll(fileBrowserDirs)} - onFilesRetryVaultDir={(vaultPath) => fileBrowser.addVaultDir(vaultPath)} - hasFileAnnotations={hasFileAnnotations} - showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} - versionInfo={versionInfo} - versions={planDiff.versions} - selectedBaseVersion={planDiff.diffBaseVersion} - onSelectBaseVersion={planDiff.selectBaseVersion} - isPlanDiffActive={isPlanDiffActive} - hasPreviousVersion={planDiff.hasPreviousVersion} - onActivatePlanDiff={() => setIsPlanDiffActive(true)} - isLoadingVersions={planDiff.isLoadingVersions} - isSelectingVersion={planDiff.isSelectingVersion} - fetchingVersion={planDiff.fetchingVersion} - onFetchVersions={planDiff.fetchVersions} - showArchiveTab={isApiMode && !annotateMode && !goalSetupMode} - archivePlans={archive.plans} - selectedArchiveFile={archive.selectedFile} - onArchiveSelect={archive.select} - isLoadingArchive={archive.isLoading} - showMessagesTab={annotateSource === 'message' && recentMessages.length > 1} - messages={recentMessages} - selectedMessageId={selectedMessageId} - onSelectMessage={handleSelectMessage} - messageAnnotationCounts={activeMessageAnnotationCounts} - /> - + +
+ 0 || codeAnnotations.length > 0 + } + callbackConfig={callbackConfig} + taterMode={taterMode} + mobileSettingsOpen={mobileSettingsOpen} + gitUser={gitUser} + onCallbackFeedback={handleCallbackFeedback} + onCallbackApprove={handleCallbackApprove} + onAnnotateExit={handleHeaderAnnotateExit} + onAnnotateFeedback={handleHeaderAnnotateFeedback} + onAnnotateApprove={handleHeaderAnnotateApprove} + onFeedback={handleHeaderFeedback} + onApprove={handleHeaderApprove} + onAnnotationPanelToggle={handleAnnotationPanelToggle} + onArchiveCopy={archive.copy} + onArchiveDone={archive.done} + onTaterModeChange={handleTaterModeChange} + onIdentityChange={handleIdentityChange} + onUIPreferencesChange={setUiPrefs} + onOpenSettings={handleOpenSettings} + onCloseSettings={handleCloseSettings} + onOpenExport={handleOpenExport} + onCopyAgentInstructions={handleHeaderCopyAgentInstructions} + onDownloadAnnotations={handleHeaderDownloadAnnotations} + onPrint={handlePrint} + onCopyShareLink={handleHeaderCopyShareLink} + onOpenImport={handleOpenImport} + onSaveToObsidian={handleSaveToObsidian} + onSaveToBear={handleSaveToBear} + onSaveToOctarine={handleSaveToOctarine} + appVersion={ + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0" + } + agentInstructionsEnabled={ + isApiMode && !archive.archiveMode && !annotateMode + } + obsidianConfigured={isObsidianConfigured()} + bearConfigured={getBearSettings().enabled} + octarineConfigured={isOctarineConfigured()} + /> + {/* Linked document error banner */} + {linkedDocHook.error && ( +
+ + {linkedDocHook.error} + +
)} - {/* Document Area */} - - -
- {/* Sticky header lane — ghost bar that pins the toolstrip + - badges at top: 12px once the user scrolls. Invisible at top - of doc; original toolstrip/badges remain the source of - truth there. Hidden in plan diff or archive mode, or when - sticky actions are disabled. remountToken re-anchors the - ResizeObserver when Viewer swaps content (linked docs or - message switches). */} - {!goalSetupMode && !isPlanDiffActive && !isHtmlSurface && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( - setIsPlanDiffActive(!isPlanDiffActive)} - archiveInfo={archive.currentInfo} - maxWidth={annotateReaderMaxWidth} - remountToken={viewerContentKey} + {/* Main Content */} + +
+ {/* Tater sprites — inside content wrapper so z-0 stacking context applies */} + {taterMode && } + {/* Left Sidebar: collapsed tab flags (when sidebar is closed) */} + {wideModeType === null && !sidebar.isOpen && ( + 1 + } + showFilesTab={showFilesTab && !archive.archiveMode} + hasFileAnnotations={hasFileAnnotations} + className="hidden lg:flex absolute left-0 top-0 z-10" /> )} - {/* Annotation Toolstrip — the mode switcher (selection/redline input + - comment/markup mode). Hidden during plan diff, and on HTML surfaces - when the header's "Hide tools" toggle is on (leaving the rendered HTML - free of overlay controls). On HTML it floats top-left over the doc. */} - {!goalSetupMode && !isPlanDiffActive && !archive.archiveMode && !(isHtmlSurface && htmlToolsHidden) && ( -
- + { + toggleSidebarTab(tab); + if (tab === "archive" && !archive.archiveMode) + archive.fetchPlans(); + }} + onClose={sidebar.close} + width={tocResize.width} + blocks={blocks} + annotations={annotations} + activeSection={activeSection} + onTocNavigate={handleTocNavigate} + linkedDocFilepath={linkedDocHook.filepath} + onLinkedDocBack={ + linkedDocHook.isActive ? handleLinkedDocBack : undefined + } + backLabel={backLabel} + showFilesTab={showFilesTab && !archive.archiveMode} + fileAnnotationCounts={fileAnnotationCounts} + highlightedFiles={highlightedFiles} + fileBrowser={fileBrowser} + onFilesSelectFile={handleFileBrowserSelect} + onFilesFetchAll={() => + fileBrowser.fetchAll(fileBrowserDirs) + } + onFilesRetryVaultDir={(vaultPath) => + fileBrowser.addVaultDir(vaultPath) + } + hasFileAnnotations={hasFileAnnotations} + showVersionsTab={ + versionInfo !== null && versionInfo.totalVersions > 1 + } + versionInfo={versionInfo} + versions={planDiff.versions} + selectedBaseVersion={planDiff.diffBaseVersion} + onSelectBaseVersion={planDiff.selectBaseVersion} + isPlanDiffActive={isPlanDiffActive} + hasPreviousVersion={planDiff.hasPreviousVersion} + onActivatePlanDiff={() => setIsPlanDiffActive(true)} + isLoadingVersions={planDiff.isLoadingVersions} + isSelectingVersion={planDiff.isSelectingVersion} + fetchingVersion={planDiff.fetchingVersion} + onFetchVersions={planDiff.fetchVersions} + showArchiveTab={isApiMode && !annotateMode} + archivePlans={archive.plans} + selectedArchiveFile={archive.selectedFile} + onArchiveSelect={archive.select} + isLoadingArchive={archive.isLoading} /> -
- )} - - {/* Plan Diff View — rendered when diff data exists, hidden when inactive */} - {goalSetupBundle && ( -
- setSubmitted('approved')} + -
+ )} - {planDiff.diffBlocks && planDiff.diffStats && !goalSetupMode && ( -
- setIsPlanDiffActive(false)} - repoInfo={repoInfo} - baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined} - baseVersion={planDiff.diffBaseVersion ?? undefined} - maxWidth={planMaxWidth} - annotations={diffAnnotations} - onAddAnnotation={handleAddAnnotation} - onSelectAnnotation={handleSelectAnnotation} - selectedAnnotationId={selectedAnnotationId} - mode={editorMode} - /> -
- )} - {/* Folder annotation empty state — shown before user picks a file */} - {annotateSource === 'folder' && !markdown && !linkedDocHook.isActive && !goalSetupMode && ( -
-
-

Select a file to annotate

-

Pick a markdown or HTML file from the sidebar to begin.

-
-
- )} - {/* Normal Plan View — always mounted, hidden during diff mode */} -
- {canUseWideMode && !isPlanDiffActive && !archive.archiveMode && !isHtmlSurface && ( + {/* Document Area */} + + +
+ {/* Sticky header lane — ghost bar that pins the toolstrip + + badges at top: 12px once the user scrolls. Invisible at top + of doc; original toolstrip/badges remain the source of + truth there. Hidden in plan diff or archive mode, or when + sticky actions are disabled. remountToken re-anchors the + ResizeObserver when Viewer swaps content (linked docs). */} + {!isPlanDiffActive && + !archive.archiveMode && + uiPrefs.stickyActionsEnabled && ( + + setIsPlanDiffActive(!isPlanDiffActive) + } + archiveInfo={archive.currentInfo} + maxWidth={annotateReaderMaxWidth} + remountToken={ + linkedDocHook.isActive + ? `doc:${linkedDocHook.filepath}` + : "plan" + } + /> + )} + + {/* Annotation Toolstrip (hidden during plan diff and archive mode) */} + {!isPlanDiffActive && !archive.archiveMode && ( +
+ +
+ )} + + {/* Plan Diff View — rendered when diff data exists, hidden when inactive */} + {planDiff.diffBlocks && planDiff.diffStats && ( +
+ setIsPlanDiffActive(false)} + repoInfo={repoInfo} + baseVersionLabel={ + planDiff.diffBaseVersion != null + ? `v${planDiff.diffBaseVersion}` + : undefined + } + baseVersion={planDiff.diffBaseVersion ?? undefined} + maxWidth={planMaxWidth} + annotations={diffAnnotations} + onAddAnnotation={handleAddAnnotation} + onSelectAnnotation={handleSelectAnnotation} + selectedAnnotationId={selectedAnnotationId} + mode={editorMode} + /> +
+ )} + {/* Folder annotation empty state — shown before user picks a file */} + {annotateSource === "folder" && + !markdown && + !linkedDocHook.isActive && ( +
+
+

+ Select a file to annotate +

+

+ Pick a markdown file from the sidebar to begin. +

+
+
+ )} + {/* Normal Plan View — always mounted, hidden during diff mode */}
-
- {(['wide', 'focus'] as const).map((type, i) => ( - - {i > 0 && |} - +
- - - - ))} -
+ {(["wide", "focus"] as const).map((type, i) => ( + + {i > 0 && ( + + | + + )} + + + + + ))} +
+
+ )} + {renderAs === "html" ? ( + + ) : ( + + setIsPlanDiffActive(!isPlanDiffActive) + } + hasPreviousVersion={ + !linkedDocHook.isActive && planDiff.hasPreviousVersion + } + showDemoBadge={ + !isApiMode && !isLoadingShared && !isSharedSession + } + maxWidth={annotateReaderMaxWidth} + onOpenLinkedDoc={handleOpenLinkedDoc} + onOpenCodeFile={codeFilePopout.open} + linkedDocInfo={ + linkedDocHook.isActive + ? { + filepath: linkedDocHook.filepath!, + onBack: handleLinkedDocBack, + label: fileBrowser.dirs.find( + (d) => d.path === fileBrowser.activeDirPath, + )?.isVault + ? "Vault File" + : fileBrowser.activeFile + ? "File" + : undefined, + backLabel, + } + : null + } + imageBaseDir={imageBaseDir} + codePathBaseDir={activeDocBaseDir} + copyLabel={ + annotateSource === "message" + ? "Copy message" + : annotateSource === "file" || + annotateSource === "folder" + ? "Copy file" + : undefined + } + archiveInfo={archive.currentInfo} + sourceInfo={sourceInfo} + onToggleCheckbox={checkbox.toggle} + checkboxOverrides={checkbox.overrides} + actionsLabelMode={actionsLabelMode} + /> + )}
- )} - {renderAs === 'html' ? ( - - ) : ( - setIsPlanDiffActive(!isPlanDiffActive)} - hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion} - showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession} - maxWidth={annotateReaderMaxWidth} - onOpenLinkedDoc={handleOpenLinkedDoc} - onOpenCodeFile={codeFilePopout.open} - linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null} - imageBaseDir={imageBaseDir} - codePathBaseDir={activeDocBaseDir} - copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined} - archiveInfo={archive.currentInfo} - sourceInfo={sourceInfo} - messagePickerInfo={ - annotateSource === 'message' && recentMessages.length > 1 - ? { - // selectedMessageId is always one of recentMessages (set on init, - // only changed via handleSelectMessage), so findIndex is >= 0. - current: recentMessages.findIndex((m) => m.messageId === selectedMessageId) + 1, - total: recentMessages.length, - onOpen: () => sidebar.open('messages'), - } - : undefined - } - onToggleCheckbox={checkbox.toggle} - checkboxOverrides={checkbox.overrides} - actionsLabelMode={actionsLabelMode} - onAskAI={canUseAI ? handleAskAI : undefined} - /> - )} -
+
+ + + {/* Resize Handle */} + {isPanelOpen && wideModeType === null && ( + + )} + + {/* Annotation Panel */} + setIsPanelOpen(false)} + onQuickCopy={async () => { + await navigator.clipboard.writeText( + wrapFeedbackForAgent(annotationsOutput), + ); + }} + onShare={ + canShareCurrentSession && (shareUrl || shortShareUrl) + ? () => { + setIsPanelOpen(false); + setInitialExportTab("share"); + setShowExport(true); + } + : undefined + } + otherFileAnnotations={otherFileAnnotations} + onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} + />
-
- - {/* Right panel region — `group/sidebar` so the collapse button reveals when - hovering the whole panel, not just the thin handle. The handle and the - panel(s) are separate sibling conditionals, so they need a shared hover - ancestor (`contents` = no layout box). */} -
- {/* Resize Handle */} - {isPanelOpen && wideModeType === null && !goalSetupMode && (rightSidebarTab === 'annotations' || canUseAI) && setIsPanelOpen(false)} />} - - {/* Annotation Panel */} - + + {/* Code File Popout */} + {codeFilePopout.popoutProps && ( + ann.filePath === codeFilePopout.popoutProps?.filepath, + )} + selectedAnnotationId={selectedCodeAnnotationId} + onAddAnnotation={handleAddCodeAnnotation} + onEditAnnotation={handleEditCodeAnnotation} + onDeleteAnnotation={handleDeleteCodeAnnotation} + onSelectAnnotation={(id) => { + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(id); + }} + /> + )} + + {/* Export Modal */} + { + setShowExport(false); + setInitialExportTab(undefined); + }} + shareUrl={shareUrl} + shareUrlSize={shareUrlSize} + shortShareUrl={shortShareUrl} + isGeneratingShortUrl={isGeneratingShortUrl} + shortUrlError={shortUrlError} + onGenerateShortUrl={generateShortUrl} + annotationsOutput={annotationsOutput} + annotationCount={allAnnotations.length + codeAnnotations.length} + taterSprite={taterMode ? : undefined} sharingEnabled={canShareCurrentSession} - width={`var(--rpanel-w, ${panelResize.width}px)`} - editorAnnotations={editorAnnotations} - onDeleteEditorAnnotation={deleteEditorAnnotation} - onClose={() => setIsPanelOpen(false)} - onQuickCopy={async () => { - const output = messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput; - await navigator.clipboard.writeText(wrapFeedbackForAgent(output)); + markdown={markdown} + isApiMode={isApiMode} + initialTab={initialExportTab} + /> + + {/* Import Modal */} + setShowImport(false)} + onImport={importFromShareUrl} + shareBaseUrl={shareBaseUrl} + /> + + {/* Feedback prompt dialog */} + setShowFeedbackPrompt(false)} + title="Add Annotations First" + message={`To provide feedback, select text in the plan and add annotations. ${agentName} will use your annotations to revise the plan.`} + variant="info" + /> + + {/* Claude Code annotation warning dialog */} + setShowClaudeCodeWarning(false)} + onConfirm={() => { + setShowClaudeCodeWarning(false); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} - onShare={canShareCurrentSession && (shareUrl || shortShareUrl) ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined} - otherFileAnnotations={otherFileAnnotations} - onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} + title="Annotations Won't Be Sent" + message={ + <> + {agentName} doesn't yet support feedback on approval. Your{" "} + {allAnnotations.length + codeAnnotations.length} annotation + {allAnnotations.length + codeAnnotations.length !== 1 + ? "s" + : ""}{" "} + will be lost. + + } + subMessage={ + <> + To send feedback, use Send Feedback instead. +
+
+ Want this feature? Upvote these issues: +
+ + #16001 + + {" · "} + + #15755 + + + } + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel /> - {isPanelOpen && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && canUseAI && ( - +
+ Plannotator will write{" "} + showClearContextOnPlanAccept: true to your Claude + Code settings so Claude Code can clear planning context through + its native approval flow. +
+
+ + +
+
)} -
-
- - - {/* Code File Popout */} - {codeFilePopout.popoutProps && ( - ann.filePath === codeFilePopout.popoutProps?.filepath)} - selectedAnnotationId={selectedCodeAnnotationId} - onAddAnnotation={handleAddCodeAnnotation} - onEditAnnotation={handleEditCodeAnnotation} - onDeleteAnnotation={handleDeleteCodeAnnotation} - onSelectAnnotation={(id) => { - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(id); + + {/* Image Annotator for pasted images */} + + + {/* Permission Mode Setup (Claude Code first-time) */} + { + setPermissionMode(mode); + setShowPermissionModeSetup(false); }} /> - )} - - {/* Export Modal */} - { setShowExport(false); setInitialExportTab(undefined); }} - shareUrl={shareUrl} - shareUrlSize={shareUrlSize} - shortShareUrl={shortShareUrl} - isGeneratingShortUrl={isGeneratingShortUrl} - shortUrlError={shortUrlError} - onGenerateShortUrl={generateShortUrl} - annotationsOutput={showExport && messageMultiSelectMode ? buildFullAnnotationsOutput() : annotationsOutput} - annotationCount={allAnnotations.length + codeAnnotations.length} - taterSprite={taterMode ? : undefined} - sharingEnabled={canShareCurrentSession} - markdown={markdown} - isApiMode={isApiMode} - initialTab={initialExportTab} - /> - - {/* Import Modal */} - setShowImport(false)} - onImport={importFromShareUrl} - shareBaseUrl={shareBaseUrl} - /> - - {/* Feedback prompt dialog */} - setShowFeedbackPrompt(false)} - title="Add Annotations First" - message={`To provide feedback, select text in the plan and add annotations. ${agentName} will use your annotations to revise the plan.`} - variant="info" - /> - - {/* Claude Code annotation warning dialog */} - setShowClaudeCodeWarning(false)} - onConfirm={() => { - setShowClaudeCodeWarning(false); - handleApprove(); - }} - title="Annotations Won't Be Sent" - message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} - subMessage={ - <> - To send feedback, use Send Feedback instead. -

- Want this feature? Upvote these issues: -
- #16001 - {' · '} - #15755 - - } - confirmText="Approve Anyway" - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* Unsaved-annotations warning dialog — reused by Close and (in gate mode) Approve */} - setShowExitWarning(false)} - onConfirm={() => { - setShowExitWarning(false); - if (exitWarningAction === 'approve') handleAnnotateApprove(); - else handleAnnotateExit(); - }} - title="Annotations Won't Be Sent" - message={<>You have {feedbackAnnotationCount} annotation{feedbackAnnotationCount !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.} - subMessage="To send your annotations, use Send Annotations instead." - confirmText={exitWarningAction === 'approve' ? 'Approve Anyway' : 'Close Anyway'} - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* OpenCode agent not found warning dialog */} - setShowAgentWarning(false)} - onConfirm={() => { - setShowAgentWarning(false); - handleApprove(); - }} - title="Agent Not Found" - message={agentWarningMessage} - subMessage={ - <> - You can change the agent in Settings, or approve anyway and OpenCode will use the default agent. - - } - confirmText="Approve Anyway" - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* Shared URL load failure warning */} - - - - - {/* Completion overlay - shown after approve/deny */} - - - - - configStore.set('gridEnabled', v)} - onDismiss={dismissLookAndFeelAnnouncement} - /> - - {/* Image Annotator for pasted images */} - - - {/* Permission Mode Setup (Claude Code first-time) */} - { - setPermissionMode(mode); - setShowPermissionModeSetup(false); - }} - /> -
+
); diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..06b27ea00 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { + buildApprovalRequestBody, + shouldEnableNativeClearBeforeApprove, +} from "./approvalBody"; + +describe("shouldEnableNativeClearBeforeApprove", () => { + test("enables native clear only for explicit Claude Code ExitPlanMode overrides", () => { + expect( + shouldEnableNativeClearBeforeApprove({ + origin: "claude-code", + toolName: "ExitPlanMode", + override: { deferToNativeForClear: true }, + }), + ).toBe(true); + + expect( + shouldEnableNativeClearBeforeApprove({ + origin: "claude-code", + toolName: "ExitPlanMode", + override: { permissionMode: "acceptEdits" }, + }), + ).toBe(false); + + expect( + shouldEnableNativeClearBeforeApprove({ + origin: "claude-code", + toolName: "OtherTool", + override: { deferToNativeForClear: true }, + }), + ).toBe(false); + }); +}); + +describe("buildApprovalRequestBody", () => { + test("omits agentSwitch for Claude Code approvals", () => { + expect( + buildApprovalRequestBody({ + origin: "claude-code", + permissionMode: "acceptEdits", + effectiveAgent: "build", + override: { + permissionMode: "bypassPermissions", + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + permissionMode: "bypassPermissions", + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test("keeps agentSwitch for OpenCode approvals", () => { + expect( + buildApprovalRequestBody({ + origin: "opencode", + permissionMode: "acceptEdits", + effectiveAgent: "build", + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + agentSwitch: "build", + planSave: { enabled: true }, + }); + }); + + test("forwards deferToNativeForClear only for explicit Claude Code ExitPlanMode bypass approvals", () => { + expect( + buildApprovalRequestBody({ + origin: "claude-code", + permissionMode: "acceptEdits", + toolName: "ExitPlanMode", + override: { + permissionMode: "bypassPermissions", + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + permissionMode: "bypassPermissions", + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test("does not forward deferToNativeForClear without ExitPlanMode", () => { + expect( + buildApprovalRequestBody({ + origin: "claude-code", + permissionMode: "acceptEdits", + toolName: "OtherTool", + override: { + permissionMode: "bypassPermissions", + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + permissionMode: "bypassPermissions", + planSave: { enabled: true }, + }); + }); + + test("does not forward deferToNativeForClear for OpenCode approvals", () => { + expect( + buildApprovalRequestBody({ + origin: "opencode", + permissionMode: "acceptEdits", + effectiveAgent: "build", + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + agentSwitch: "build", + planSave: { enabled: true }, + }); + }); + + test("does not forward deferToNativeForClear for Gemini origin", () => { + expect( + buildApprovalRequestBody({ + origin: "gemini-cli", + permissionMode: "acceptEdits", + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + }), + ).toEqual({ + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..5536fff4f --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,81 @@ +import type { Origin } from "@plannotator/shared/agents"; +import type { PermissionMode } from "@plannotator/ui/utils/permissionMode"; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +} + +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === "claude-code" && + options.toolName === "ExitPlanMode" && + options.override?.deferToNativeForClear === true + ); +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; +}): ApprovalRequestBody { + const { + origin, + permissionMode, + override = {}, + effectiveAgent, + planSaveSettings, + toolName, + } = options; + const body: ApprovalRequestBody = {}; + + if (origin === "claude-code") { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const useNativeClear = shouldEnableNativeClearBeforeApprove({ + origin, + toolName, + override, + }); + + body.permissionMode = effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge) { + body.clearContextNudge = true; + } + } + + if (origin === "opencode" && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { + customPath: planSaveSettings.customPath, + }), + }; + + return body; +} diff --git a/packages/editor/components/AppHeader.test.tsx b/packages/editor/components/AppHeader.test.tsx new file mode 100644 index 000000000..aa92e09d1 --- /dev/null +++ b/packages/editor/components/AppHeader.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AppHeader } from "./AppHeader"; +import type { ApproveExtraEntry } from "@plannotator/ui/components/ApproveDropdown"; + +const noop = () => {}; + +function renderHeader( + overrides: Partial> = {}, +) { + const props: React.ComponentProps = { + isApiMode: true, + annotateMode: false, + archiveMode: false, + gate: false, + isSharedSession: false, + origin: "claude-code", + isSubmitting: false, + isExiting: false, + isPanelOpen: false, + hasAnyAnnotations: false, + linkedDocIsActive: false, + callbackShareUrlReady: true, + canShareCurrentSession: false, + agentName: "Claude Code", + availableAgents: [], + showAnnotationsWarning: false, + callbackConfig: null, + taterMode: false, + mobileSettingsOpen: false, + gitUser: undefined, + onCallbackFeedback: noop, + onCallbackApprove: noop, + onAnnotateExit: noop, + onAnnotateFeedback: noop, + onAnnotateApprove: noop, + onFeedback: noop, + onApprove: noop, + onAnnotationPanelToggle: noop, + onArchiveCopy: noop, + onArchiveDone: noop, + onTaterModeChange: noop, + onIdentityChange: noop, + onUIPreferencesChange: noop, + onOpenSettings: noop, + onCloseSettings: noop, + onOpenExport: noop, + onCopyAgentInstructions: noop, + onDownloadAnnotations: noop, + onPrint: noop, + onCopyShareLink: noop, + onOpenImport: noop, + onSaveToObsidian: noop, + onSaveToBear: noop, + onSaveToOctarine: noop, + appVersion: "test", + agentInstructionsEnabled: false, + obsidianConfigured: false, + bearConfigured: false, + octarineConfigured: false, + ...overrides, + }; + + return renderToStaticMarkup(); +} + +describe("AppHeader approval actions", () => { + test("renders a Claude Code approval dropdown when extra approval entries are provided", () => { + const extraEntries: ApproveExtraEntry[] = [ + { + id: "approve-bypass-native-clear", + label: "Approve + Bypass + Clear Context (native)", + onSelect: noop, + }, + ]; + + const html = renderHeader({ approveExtraEntries: extraEntries }); + + expect(html).toContain('aria-label="More approval options"'); + expect(html).toContain("Approve"); + }); + + test("keeps Claude Code on the plain approve button when no extra entries are provided", () => { + const html = renderHeader(); + + expect(html).not.toContain('aria-label="More approval options"'); + expect(html).toContain("Approve"); + }); + + test("keeps annotate gate approvals plain even when plan-review extra entries exist", () => { + const extraEntries: ApproveExtraEntry[] = [ + { + id: "approve-bypass-native-clear", + label: "Approve + Bypass + Clear Context (native)", + onSelect: noop, + }, + ]; + + const html = renderHeader({ + annotateMode: true, + gate: true, + approveExtraEntries: extraEntries, + }); + + expect(html).not.toContain('aria-label="More approval options"'); + expect(html).toContain("Approve"); + }); +}); diff --git a/packages/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index 619761ecb..3242119a5 100644 --- a/packages/editor/components/AppHeader.tsx +++ b/packages/editor/components/AppHeader.tsx @@ -4,6 +4,7 @@ import type { Agent } from '@plannotator/ui/hooks/useAgents'; import type { UpdateInfo } from '@plannotator/ui/hooks/useUpdateCheck'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown'; +import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; import { Settings } from '@plannotator/ui/components/Settings'; import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu'; import type { CallbackConfig } from '@plannotator/ui/utils/callback'; @@ -41,6 +42,7 @@ interface AppHeaderProps { canShareCurrentSession: boolean; agentName: string; availableAgents: Agent[]; + approveExtraEntries?: ApproveExtraEntry[]; showAnnotationsWarning: boolean; // Callback config (null when no bot callback) @@ -116,6 +118,7 @@ export const AppHeader = React.memo(({ canShareCurrentSession, agentName, availableAgents, + approveExtraEntries = [], showAnnotationsWarning, callbackConfig, taterMode, @@ -156,6 +159,22 @@ export const AppHeader = React.memo(({ bearConfigured, octarineConfigured, }) => { + const showApproveWarning = !annotateMode + && (origin === 'claude-code' || origin === 'gemini-cli') + && showAnnotationsWarning; + const showAgentSwitch = origin === 'opencode' && !annotateMode && availableAgents.length > 0; + const showApproveExtraEntries = !annotateMode && approveExtraEntries.length > 0; + const showApproveDropdown = (!annotateMode || gate) + && (showAgentSwitch || showApproveExtraEntries); + + const approveWarningTooltip = showApproveWarning && ( +
+
+
+ {agentName} doesn't support feedback on approval. Your annotations won't be seen. +
+ ); + return (
@@ -265,29 +284,30 @@ export const AppHeader = React.memo(({ )} {(!annotateMode || gate) && ( - origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? ( - + showApproveDropdown ? ( +
+ + {approveWarningTooltip} +
) : (
- {!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning && ( -
-
-
- {agentName} doesn't support feedback on approval. Your annotations won't be seen. -
- )} + {approveWarningTooltip}
) )} diff --git a/packages/server/index.ts b/packages/server/index.ts index a2c68b0b3..7c192b938 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -13,7 +13,10 @@ */ import type { Origin } from "@plannotator/shared/agents"; -import { resolve } from "path"; +import { randomBytes } from "crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { openEditorDiff } from "./ide"; import { @@ -62,6 +65,10 @@ export * from "./storage"; export { handleServerReady } from "./shared-handlers"; export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + // --- Types --- export interface ServerOptions { @@ -73,6 +80,8 @@ export interface ServerOptions { htmlContent: string; /** Current permission mode to preserve (Claude Code only) */ permissionMode?: string; + /** Tool name from the permission request, e.g. ExitPlanMode */ + toolName?: string; /** Whether URL sharing is enabled (default: true) */ sharingEnabled?: boolean; /** Custom base URL for share links (default: https://share.plannotator.ai) */ @@ -103,6 +112,8 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -114,6 +125,64 @@ export interface ServerResult { const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; +const CLEAR_CONTEXT_SETTING_KEY = "showClearContextOnPlanAccept"; + +function clearContextConsentPath(): string { + return join(homedir(), ".plannotator", "consent", "clear-context-setting.json"); +} + +function clearContextSettingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +function readClearContextSettings(): Record | null { + if (!existsSync(clearContextSettingsPath())) return {}; + try { + const parsed = JSON.parse(readFileSync(clearContextSettingsPath(), "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return null; + } +} + +function hasClearContextConsent(): boolean { + try { + if (!existsSync(clearContextConsentPath())) return false; + const parsed = JSON.parse(readFileSync(clearContextConsentPath(), "utf8")); + return parsed?.consented === true; + } catch { + return false; + } +} + +function writeJsonAtomic(path: string, data: Record): void { + const tmp = join( + dirname(path), + `plannotator-settings-${randomBytes(4).toString("hex")}.json`, + ); + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +function recordClearContextConsent(): void { + mkdirSync(join(homedir(), ".plannotator", "consent"), { recursive: true }); + writeJsonAtomic(clearContextConsentPath(), { + consented: true, + recordedAt: new Date().toISOString(), + }); +} + +function enableClearContextSetting(): "ok" | "malformed" { + const settings = readClearContextSettings(); + if (settings === null) return "malformed"; + settings[CLEAR_CONTEXT_SETTING_KEY] = true; + mkdirSync(join(homedir(), ".claude"), { recursive: true }); + recordClearContextConsent(); + writeJsonAtomic(clearContextSettingsPath(), settings); + return "ok"; +} /** * Start the Plannotator server @@ -127,7 +196,7 @@ const RETRY_DELAY_MS = 500; export async function startPlannotatorServer( options: ServerOptions ): Promise { - const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; + const { plan, origin, htmlContent, permissionMode, toolName, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; const isRemote = isRemoteSession(); const configuredPort = getServerPort(); @@ -175,6 +244,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -182,6 +253,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -287,7 +360,7 @@ export async function startPlannotatorServer( serverConfig: getServerConfig(gitUser), }); } - return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); + return Response.json({ plan, origin, permissionMode, toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); } // API: Serve a linked markdown document @@ -313,7 +386,7 @@ export async function startPlannotatorServer( pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, @@ -475,6 +548,8 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -486,6 +561,8 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -503,6 +580,14 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } + // Capture optional /clear reminder request for Claude Code approval flow + if (body.clearContextNudge === true) { + clearContextNudge = true; + } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } + // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -548,10 +633,35 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } + if (url.pathname === "/api/settings-status" && req.method === "GET") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const settings = readClearContextSettings(); + return Response.json({ + settingEnabled: settings?.[CLEAR_CONTEXT_SETTING_KEY] === true, + consentGiven: hasClearContextConsent(), + }); + } + + if (url.pathname === "/api/enable-clear-context" && req.method === "POST") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const result = enableClearContextSetting(); + if (result === "malformed") { + return Response.json( + { ok: false, error: "Malformed Claude Code settings JSON" }, + { status: 400 }, + ); + } + return Response.json({ ok: true }); + } + // API: Deny with feedback if (url.pathname === "/api/deny" && req.method === "POST") { let feedback = "Plan rejected by user"; diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index c2163a6ec..5f453dc78 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -1,12 +1,18 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types'; -import { isCurrentUser } from '../utils/identity'; -import { ImageThumbnail } from './ImageThumbnail'; -import { EditorAnnotationCard } from './EditorAnnotationCard'; -import { useIsMobile } from '../hooks/useIsMobile'; -import { OverlayScrollArea } from './OverlayScrollArea'; -import { Button } from './ui/button'; -import { cn } from '../lib/utils'; +import React, { useState, useRef, useEffect } from "react"; +import { + Annotation, + AnnotationType, + Block, + type CodeAnnotation, + type EditorAnnotation, +} from "../types"; +import { isCurrentUser } from "../utils/identity"; +import { ImageThumbnail } from "./ImageThumbnail"; +import { EditorAnnotationCard } from "./EditorAnnotationCard"; +import { useIsMobile } from "../hooks/useIsMobile"; +import { OverlayScrollArea } from "./OverlayScrollArea"; +import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; // Card type-word colors. Deletion uses `destructive` (reliably red on every // theme, matching the in-document .deletion highlight). Comment uses the @@ -15,26 +21,46 @@ import { cn } from '../lib/utils'; // blue in neutral themes whose accent is a low-contrast gray (e.g. "simple"). // Global has no in-document highlight, so it uses a fixed legible purple. const TYPE_COLOR: Record = { - [AnnotationType.DELETION]: 'text-destructive', - [AnnotationType.COMMENT]: 'text-annotation-comment', - [AnnotationType.GLOBAL_COMMENT]: 'text-purple-500', + [AnnotationType.DELETION]: "text-destructive", + [AnnotationType.COMMENT]: "text-annotation-comment", + [AnnotationType.GLOBAL_COMMENT]: "text-purple-500", }; const TYPE_LABEL: Record = { - [AnnotationType.DELETION]: 'Deletion', - [AnnotationType.COMMENT]: 'Comment', - [AnnotationType.GLOBAL_COMMENT]: 'Global', + [AnnotationType.DELETION]: "Deletion", + [AnnotationType.COMMENT]: "Comment", + [AnnotationType.GLOBAL_COMMENT]: "Global", }; const PencilIcon = () => ( - - + + ); const TrashCardIcon = () => ( - - + + ); @@ -86,20 +112,37 @@ export const AnnotationPanel: React.FC = ({ const isMobile = useIsMobile(); const [copiedText, setCopiedText] = useState(false); const listRef = useRef(null); - const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA); - const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt); + const sortedAnnotations = [...annotations].sort( + (a, b) => a.createdA - b.createdA, + ); + const sortedCodeAnnotations = [...codeAnnotations].sort( + (a, b) => a.createdAt - b.createdAt, + ); const timelineEntries = [ - ...sortedAnnotations.map(annotation => ({ kind: 'plan' as const, ts: annotation.createdA, annotation })), - ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })), + ...sortedAnnotations.map((annotation) => ({ + kind: "plan" as const, + ts: annotation.createdA, + annotation, + })), + ...sortedCodeAnnotations.map((annotation) => ({ + kind: "code" as const, + ts: annotation.createdAt, + annotation, + })), ].sort((a, b) => a.ts - b.ts); - const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0); + const totalCount = + annotations.length + + codeAnnotations.length + + (editorAnnotations?.length ?? 0); // Scroll selected annotation card into view useEffect(() => { if (!selectedId || !listRef.current) return; - const card = listRef.current.querySelector(`[data-annotation-id="${selectedId}"]`); + const card = listRef.current.querySelector( + `[data-annotation-id="${selectedId}"]`, + ); if (card) { - card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + card.scrollIntoView({ behavior: "smooth", block: "center" }); } }, [selectedId]); @@ -110,7 +153,9 @@ export const AnnotationPanel: React.FC = ({ data-annotation-panel="true" data-plan-sidebar="right" className={`border-l border-border/50 bg-card flex flex-col flex-shrink-0 ${ - isMobile ? 'fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : '' + isMobile + ? "fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card" + : "" }`} style={isMobile ? undefined : { width: width ?? 288 }} > @@ -118,7 +163,7 @@ export const AnnotationPanel: React.FC = ({
-

+

Annotations

{totalCount > 0 && ( @@ -134,8 +179,18 @@ export const AnnotationPanel: React.FC = ({ title="Close panel" aria-label="Close panel" > - - + + )} @@ -146,7 +201,8 @@ export const AnnotationPanel: React.FC = ({ className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer" title="Show annotated files in sidebar" > - +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'} + +{otherFileAnnotations.count} in {otherFileAnnotations.files} other + file{otherFileAnnotations.files === 1 ? "" : "s"} )}
@@ -154,61 +210,73 @@ export const AnnotationPanel: React.FC = ({ {/* List */}
- {totalCount === 0 ? ( -
-

- No annotations yet -

-

- Select text to annotate -

-
- ) : ( - <> - {timelineEntries.map(entry => ( - entry.kind === 'plan' ? ( - onSelect(entry.annotation.id)} - onDelete={() => onDelete(entry.annotation.id)} - onEdit={onEdit ? (updates: Partial) => onEdit(entry.annotation.id, updates) : undefined} - /> - ) : ( - onSelectCodeAnnotation?.(entry.annotation.id)} - onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)} - onEdit={onEditCodeAnnotation ? (updates: Partial) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined} - /> - ) - ))} - {editorAnnotations && editorAnnotations.length > 0 && ( - <> - {timelineEntries.length > 0 && ( -
-
- Editor -
-
- )} - {editorAnnotations.map(ann => ( - onDeleteEditorAnnotation?.(ann.id)} + {totalCount === 0 ? ( +
+

+ Select text or code lines to add annotations +

+
+ ) : ( + <> + {timelineEntries.map((entry) => + entry.kind === "plan" ? ( + onSelect(entry.annotation.id)} + onDelete={() => onDelete(entry.annotation.id)} + onEdit={ + onEdit + ? (updates: Partial) => + onEdit(entry.annotation.id, updates) + : undefined + } /> - ))} - - )} - - - )} + ) : ( + + onSelectCodeAnnotation?.(entry.annotation.id) + } + onDelete={() => + onDeleteCodeAnnotation?.(entry.annotation.id) + } + onEdit={ + onEditCodeAnnotation + ? (updates: Partial) => + onEditCodeAnnotation(entry.annotation.id, updates) + : undefined + } + /> + ), + )} + {editorAnnotations && editorAnnotations.length > 0 && ( + <> + {timelineEntries.length > 0 && ( +
+
+ + Editor + +
+
+ )} + {editorAnnotations.map((ann) => ( + onDeleteEditorAnnotation?.(ann.id)} + /> + ))} + + )} + + )}
@@ -223,20 +291,42 @@ export const AnnotationPanel: React.FC = ({ setTimeout(() => setCopiedText(false), 2000); }} className={`flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium transition-colors ${ - copiedText ? 'text-green-500' : 'text-muted-foreground hover:bg-surface-1 hover:text-foreground' + copiedText + ? "text-green-500" + : "text-muted-foreground hover:bg-surface-1 hover:text-foreground" }`} > {copiedText ? ( <> - - + + Copied ) : ( <> - - + + Copy @@ -248,8 +338,18 @@ export const AnnotationPanel: React.FC = ({ onClick={onShare} className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium transition-colors text-muted-foreground hover:bg-surface-1 hover:text-foreground" > - - + + Share @@ -282,12 +382,15 @@ function formatTimestamp(ts: number): string { const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); - if (seconds < 60) return 'now'; + if (seconds < 60) return "now"; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return new Date(ts).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); } const AnnotationCard: React.FC<{ @@ -299,7 +402,7 @@ const AnnotationCard: React.FC<{ onEdit?: (updates: Partial) => void; }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { const [isEditing, setIsEditing] = useState(false); - const [editText, setEditText] = useState(annotation.text || ''); + const [editText, setEditText] = useState(annotation.text || ""); const textareaRef = useRef(null); useEffect(() => { @@ -312,13 +415,13 @@ const AnnotationCard: React.FC<{ // Update editText when annotation.text changes useEffect(() => { if (!isEditing) { - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); } }, [annotation.text, isEditing]); const handleStartEdit = (e: React.MouseEvent) => { e.stopPropagation(); - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); setIsEditing(true); }; @@ -330,22 +433,26 @@ const AnnotationCard: React.FC<{ }; const handleCancelEdit = () => { - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) && + !e.nativeEvent.isComposing + ) { e.preventDefault(); handleSaveEdit(); - } else if (e.key === 'Escape') { + } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); } }; - const typeColor = TYPE_COLOR[annotation.type] ?? 'text-muted-foreground'; - const typeLabel = TYPE_LABEL[annotation.type] ?? 'Note'; + const typeColor = TYPE_COLOR[annotation.type] ?? "text-muted-foreground"; + const typeLabel = TYPE_LABEL[annotation.type] ?? "Note"; const isGlobal = annotation.type === AnnotationType.GLOBAL_COMMENT; // Shared edit textarea — matches the prototype composer primitive @@ -354,16 +461,22 @@ const AnnotationCard: React.FC<{
- - + +
); @@ -375,41 +488,56 @@ const AnnotationCard: React.FC<{ tabIndex={0} onClick={onSelect} onKeyDown={(e: React.KeyboardEvent) => { - if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { + if ( + (e.key === "Enter" || e.key === " ") && + e.target === e.currentTarget + ) { e.preventDefault(); onSelect(); } }} className={cn( - 'group w-full cursor-pointer rounded-lg px-3 py-2.5 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50', - isSelected ? 'bg-surface-1 ring-1 ring-border/50' : 'hover:bg-surface-1/50', + "group w-full cursor-pointer rounded-lg px-3 py-2.5 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + isSelected + ? "bg-surface-1 ring-1 ring-border/50" + : "hover:bg-surface-1/50", )} > {/* Header: type word + author · time + actions */}
- {typeLabel} + + {typeLabel} + {annotation.diffContext && ( diff )} - {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)} + {annotation.author + ? `${annotation.author}${isMe ? " (me)" : ""} · ` + : ""} + {formatTimestamp(annotation.createdA)}
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( - - )} + {onEdit && + annotation.type !== AnnotationType.DELETION && + !isEditing && ( + + )}
{/* File / line meta */} -
+
{fileName} · {lineRange}
@@ -569,10 +713,14 @@ const CodeAnnotationCard: React.FC<{ value={editText} onChange={(e) => setEditText(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) && + !e.nativeEvent.isComposing + ) { e.preventDefault(); handleSaveEdit(); - } else if (e.key === 'Escape') { + } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); } @@ -580,11 +728,21 @@ const CodeAnnotationCard: React.FC<{ placeholder="Add your comment..." aria-label="Annotation comment" className="w-full resize-none rounded-lg border border-border/50 bg-card px-2.5 py-2 text-base leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20" - style={{ fieldSizing: 'content', minHeight: 44 } as React.CSSProperties} + style={ + { fieldSizing: "content", minHeight: 44 } as React.CSSProperties + } />
- - + +
) : ( @@ -600,7 +758,12 @@ const CodeAnnotationCard: React.FC<{ {annotation.images.map((img) => (
-
{img.name}
+
+ {img.name} +
))}
diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..68e87f11c --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ApproveDropdown } from "./ApproveDropdown"; + +describe("ApproveDropdown", () => { + test("does not show agent-switch label when only extra approval entries are enabled", () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[ + { + id: "approve-bypass-native-clear", + label: "Approve + Bypass + Clear Context (native)", + onSelect: () => {}, + }, + ]} + />, + ); + + expect(html).toContain("Approve"); + expect(html).not.toContain("build"); + expect(html).not.toContain("(?)"); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..f83c09961 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,23 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + dimmed?: boolean; + title?: string; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +47,10 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + dimmed = false, + title, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,20 +73,26 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled ? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground' + : dimmed + ? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground' : 'bg-success text-success-foreground hover:opacity-90'; const handleApproveClick = () => { @@ -78,22 +100,44 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index 0ac5bfeae..f32e8938e 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -20,15 +20,28 @@ export const BlockRenderer: React.FC<{ githubRepo?: string; headingAnchorId?: string; onNavigateAnchor?: (hash: string) => void; -}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => { +}> = ({ + block, + onOpenLinkedDoc, + onOpenCodeFile, + imageBaseDir, + onImageClick, + onToggleCheckbox, + checkboxOverrides, + orderedIndex, + githubRepo, + headingAnchorId, + onNavigateAnchor, +}) => { switch (block.type) { - case 'heading': { + case "heading": { const Tag = `h${block.level || 1}` as React.ElementType; - const styles = { - 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight', - 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90', - 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', - }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; + const styles = + { + 1: "text-2xl font-bold mb-6 mt-8 first:mt-0 tracking-tight", + 2: "text-xl font-semibold mb-4 mt-10 text-foreground/90", + 3: "text-base font-semibold mb-3 mt-8 text-foreground/80", + }[block.level || 1] || "text-base font-semibold mb-2 mt-4"; return ( - + ); } - case 'blockquote': { + case "blockquote": { if (block.alertKind) { return ( {paragraphs.map((para, i) => ( -

0 ? 'mt-2' : ''}> - +

0 ? "mt-2" : ""}> +

))} ); } - case 'list-item': { + case "list-item": { const indent = (block.level || 0) * 1.25; // 1.25rem per level const isCheckbox = block.checked !== undefined; const isChecked = checkboxOverrides?.has(block.id) ? checkboxOverrides.get(block.id)! : block.checked; const isInteractive = isCheckbox && !!onToggleCheckbox; - const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`; - const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor }; + const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? "text-muted-foreground line-through" : "text-foreground/90"}`; + const inlineProps = { + imageBaseDir, + onImageClick, + onOpenLinkedDoc, + onOpenCodeFile, + githubRepo, + onNavigateAnchor, + }; return (
@@ -95,19 +131,32 @@ export const BlockRenderer: React.FC<{ orderedIndex={orderedIndex} checked={isChecked} interactive={isInteractive} - onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined} + onToggle={ + isInteractive + ? () => onToggleCheckbox!(block.id, !isChecked) + : undefined + } textClassName={textClass} content={block.content} - renderInline={(text) => } + renderInline={(text) => ( + + )} />
); } - case 'code': - return {}} onLeave={() => {}} isHovered={false} />; + case "code": + return ( + {}} + onLeave={() => {}} + isHovered={false} + /> + ); - case 'table': + case "table": return ( ); - case 'hr': - return
; + case "hr": + return
; - case 'html': - return ; + case "html": + return ( + + ); - case 'directive': { - const kind = block.directiveKind || 'note'; + case "directive": { + const kind = block.directiveKind || "note"; return ( - +

); } diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index d4ce85670..5903ce185 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -610,7 +611,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -816,6 +817,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 8bb4d5d9c..89f7f9015 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,19 +1,35 @@ -import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import hljs from 'highlight.js'; -import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; -import { Frontmatter, computeListIndices } from '../utils/parser'; -import { buildHeadingSlugMap } from '../utils/slugify'; -import { BlockRenderer } from './BlockRenderer'; -import { CodeBlock } from './blocks/CodeBlock'; -import { TableBlock } from './blocks/TableBlock'; -import { TableToolbar } from './blocks/TableToolbar'; -import { TablePopout } from './blocks/TablePopout'; -import { CodePathValidationContext } from './CodePathValidationContext'; -import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths'; -import { ListMarker } from './ListMarker'; -import { AnnotationToolbar } from './AnnotationToolbar'; -import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; +import React, { + useRef, + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, + useCallback, +} from "react"; +import { createPortal } from "react-dom"; +import hljs from "highlight.js"; +import { + Block, + Annotation, + AnnotationType, + EditorMode, + type InputMethod, + type ImageAttachment, + type ActionsLabelMode, +} from "../types"; +import { Frontmatter, computeListIndices } from "../utils/parser"; +import { buildHeadingSlugMap } from "../utils/slugify"; +import { BlockRenderer } from "./BlockRenderer"; +import { CodeBlock } from "./blocks/CodeBlock"; +import { TableBlock } from "./blocks/TableBlock"; +import { TableToolbar } from "./blocks/TableToolbar"; +import { TablePopout } from "./blocks/TablePopout"; +import { CodePathValidationContext } from "./CodePathValidationContext"; +import { useValidatedCodePaths } from "../hooks/useValidatedCodePaths"; +import { ListMarker } from "./ListMarker"; +import { AnnotationToolbar } from "./AnnotationToolbar"; +import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; // Debug error boundary to catch silent toolbar crashes class ToolbarErrorBoundary extends React.Component< @@ -21,34 +37,52 @@ class ToolbarErrorBoundary extends React.Component< { error: Error | null } > { state = { error: null as Error | null }; - static getDerivedStateFromError(error: Error) { return { error }; } - componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); } + static getDerivedStateFromError(error: Error) { + return { error }; + } + componentDidCatch(error: Error) { + console.error("AnnotationToolbar crashed:", error); + } render() { if (this.state.error) { - return
- Toolbar error: {this.state.error.message} -
; + return ( +
+ Toolbar error: {this.state.error.message} +
+ ); } return this.props.children; } } -import { CommentPopover, type CommentAskAIContext } from './CommentPopover'; -import { TaterSpriteSitting } from './TaterSpriteSitting'; -import { AttachmentsButton } from './AttachmentsButton'; -import { MessagesIcon } from './icons/MessagesIcon'; -import { GraphvizBlock } from './GraphvizBlock'; -import { MermaidBlock } from './MermaidBlock'; -import { getImageSrc } from './ImageThumbnail'; -import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; -import { getIdentity } from '../utils/identity'; -import { type QuickLabel } from '../utils/quickLabels'; -import { DocBadges } from './DocBadges'; -import { PinpointOverlay } from './PinpointOverlay'; -import { usePinpoint } from '../hooks/usePinpoint'; -import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter'; -import { useScrollViewport } from '../hooks/useScrollViewport'; -import { decodeAnchorHash } from '../utils/anchors'; +import { CommentPopover, type CommentAskAIContext } from "./CommentPopover"; +import { TaterSpriteSitting } from "./TaterSpriteSitting"; +import { AttachmentsButton } from "./AttachmentsButton"; +import { MessagesIcon } from "./icons/MessagesIcon"; +import { GraphvizBlock } from "./GraphvizBlock"; +import { MermaidBlock } from "./MermaidBlock"; +import { getImageSrc } from "./ImageThumbnail"; +import { isGraphvizLanguage, isMermaidLanguage } from "./diagramLanguages"; +import { getIdentity } from "../utils/identity"; +import { type QuickLabel } from "../utils/quickLabels"; +import { DocBadges } from "./DocBadges"; +import { PinpointOverlay } from "./PinpointOverlay"; +import { usePinpoint } from "../hooks/usePinpoint"; +import { useAnnotationHighlighter } from "../hooks/useAnnotationHighlighter"; +import { useScrollViewport } from "../hooks/useScrollViewport"; +import { decodeAnchorHash } from "../utils/anchors"; interface ViewerProps { blocks: Block[]; @@ -75,9 +109,18 @@ interface ViewerProps { * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc) * resolve against the doc's own directory rather than only cwd. */ codePathBaseDir?: string; - linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null; + linkedDocInfo?: { + filepath: string; + onBack: () => void; + label?: string; + backLabel?: string; + } | null; // Plan diff props - planDiffStats?: { additions: number; deletions: number; modifications: number } | null; + planDiffStats?: { + additions: number; + deletions: number; + modifications: number; + } | null; isPlanDiffActive?: boolean; onPlanDiffToggle?: () => void; hasPreviousVersion?: boolean; @@ -93,7 +136,11 @@ interface ViewerProps { * callers that don't measure plan-area width. */ actionsLabelMode?: ActionsLabelMode; - archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; + archiveInfo?: { + status: "approved" | "denied" | "unknown"; + timestamp: string; + title: string; + } | null; /** Source attribution for HTML/URL annotations (e.g. URL or filename) */ sourceInfo?: string; /** @@ -118,7 +165,9 @@ export interface ViewerHandle { /** * Renders YAML frontmatter as a styled metadata card. */ -const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }) => { +const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ + frontmatter, +}) => { const entries = Object.entries(frontmatter); if (entries.length === 0) return null; @@ -127,12 +176,17 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
{entries.map(([key, value]) => (
- {key}: + + {key}: + {Array.isArray(value) ? ( {value.map((v, i) => ( - + {v} ))} @@ -148,775 +202,986 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter } ); }; -export const Viewer = forwardRef(({ - blocks, - markdown, - frontmatter, - annotations, - onAddAnnotation, - onSelectAnnotation, - selectedAnnotationId, - mode, - inputMethod = 'drag', - taterMode, - globalAttachments = [], - onAddGlobalAttachment, - onRemoveGlobalAttachment, - repoInfo, - stickyActions = true, - gridEnabled = false, - planDiffStats, - isPlanDiffActive, - onPlanDiffToggle, - hasPreviousVersion, - showDemoBadge, - maxWidth, - onOpenLinkedDoc, - onOpenCodeFile, - linkedDocInfo, - imageBaseDir, - codePathBaseDir, - copyLabel, - actionsLabelMode = 'full', - archiveInfo, - sourceInfo, - messagePickerInfo, - onToggleCheckbox, - checkboxOverrides, - onAskAI, -}, ref) => { - const [copied, setCopied] = useState(false); - const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); - const [locationHash, setLocationHash] = useState(() => window.location.hash); - const globalCommentButtonRef = useRef(null); - - const handleCopyPlan = async () => { - try { - await navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }; - const containerRef = useRef(null); - // Per-doc heading slug map with dedup — computed once per blocks array so - // anchor ids stay stable across re-renders and duplicate heading texts get - // `-1`/`-2`/... suffixes rather than colliding on the same id. - const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]); - const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []); - const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null); - const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false); - const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null); - const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); - const tableHoverTimeoutRef = useRef(null); - const [popoutTable, setPopoutTable] = useState(null); - // Viewer-specific comment popover state (global comments + code blocks) - const [viewerCommentPopover, setViewerCommentPopover] = useState<{ - anchorEl: HTMLElement; - contextText: string; - selectedText?: string; - initialText?: string; - isGlobal: boolean; - codeBlock?: { block: Block; element: HTMLElement }; - } | null>(null); - // Viewer-specific quick label state (code blocks) - const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{ - anchorEl: HTMLElement; - codeBlock: { block: Block; element: HTMLElement }; - } | null>(null); - const hoverTimeoutRef = useRef(null); - const stickySentinelRef = useRef(null); - const lastAutoScrolledHashRef = useRef(null); - const [isStuck, setIsStuck] = useState(false); - - // Shared annotation infrastructure via hook - const { - highlighterRef, - toolbarState, - commentPopover: hookCommentPopover, - quickLabelPicker: hookQuickLabelPicker, - handleAnnotate, - handleQuickLabel, - handleToolbarClose, - handleRequestComment, - handleCommentSubmit: hookCommentSubmit, - handleCommentClose: hookCommentClose, - handleFloatingQuickLabel: hookFloatingQuickLabel, - handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss, - removeHighlight: hookRemoveHighlight, - clearAllHighlights, - applyAnnotations, - } = useAnnotationHighlighter({ - containerRef, - annotations, - onAddAnnotation, - onSelectAnnotation, - selectedAnnotationId, - mode, - }); - - // Refs for code block annotation path - const onAddAnnotationRef = useRef(onAddAnnotation); - useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); - const modeRef = useRef(mode); - useEffect(() => { modeRef.current = mode; }, [mode]); - - // Pinpoint mode: hover + click to select elements - const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => { - const codeEl = element.querySelector('code'); - if (!codeEl) return; - // In pinpoint mode, apply code block annotation based on current editor mode - if (modeRef.current === 'redline') { - applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION); - } else if (modeRef.current === 'quickLabel') { - setCodeBlockQuickLabelPicker({ - anchorEl: element, - codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, - }); - } else { - // Show comment popover anchored to the code block - setViewerCommentPopover({ - anchorEl: element, - contextText: (codeEl.textContent || '').slice(0, 80), - selectedText: codeEl.textContent || '', - isGlobal: false, - codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, - }); - } - }, [blocks]); - - const { hoverTarget } = usePinpoint({ - containerRef, - highlighterRef, - inputMethod, - enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), - onCodeBlockClick: handlePinpointCodeBlockClick, - }); - - // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile) - useEffect(() => { - const container = containerRef.current; - if (!container || !isTouchDevice) return; - - const handleContextMenu = (e: Event) => { - e.preventDefault(); +export const Viewer = forwardRef( + ( + { + blocks, + markdown, + frontmatter, + annotations, + onAddAnnotation, + onSelectAnnotation, + selectedAnnotationId, + mode, + inputMethod = "drag", + taterMode, + globalAttachments = [], + onAddGlobalAttachment, + onRemoveGlobalAttachment, + repoInfo, + stickyActions = true, + gridEnabled = false, + planDiffStats, + isPlanDiffActive, + onPlanDiffToggle, + hasPreviousVersion, + showDemoBadge, + maxWidth, + onOpenLinkedDoc, + onOpenCodeFile, + linkedDocInfo, + imageBaseDir, + codePathBaseDir, + copyLabel, + actionsLabelMode = "full", + archiveInfo, + sourceInfo, + messagePickerInfo, + onToggleCheckbox, + checkboxOverrides, + onAskAI, + }, + ref, + ) => { + const [copied, setCopied] = useState(false); + const [lightbox, setLightbox] = useState<{ + src: string; + alt: string; + } | null>(null); + const [locationHash, setLocationHash] = useState( + () => window.location.hash, + ); + const globalCommentButtonRef = useRef(null); + + const handleCopyPlan = async () => { + try { + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error("Failed to copy:", e); + } }; - - container.addEventListener('contextmenu', handleContextMenu); - return () => container.removeEventListener('contextmenu', handleContextMenu); - }, []); - - // Detect when sticky action bar is "stuck" to show card background. - // The IntersectionObserver root must be the actual scroll element — the - // OverlayScrollArea viewport — not the
host, which doesn't scroll. - const stickyScrollViewport = useScrollViewport(); - useEffect(() => { - if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) return; - const observer = new IntersectionObserver( - ([entry]) => setIsStuck(!entry.isIntersecting), - { root: stickyScrollViewport, threshold: 0 } + const containerRef = useRef(null); + // Per-doc heading slug map with dedup — computed once per blocks array so + // anchor ids stay stable across re-renders and duplicate heading texts get + // `-1`/`-2`/... suffixes rather than colliding on the same id. + const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]); + const isTouchDevice = useMemo( + () => window.matchMedia("(pointer: coarse)").matches, + [], ); - observer.observe(stickySentinelRef.current); - return () => observer.disconnect(); - }, [stickyActions, stickyScrollViewport]); + const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ + block: Block; + element: HTMLElement; + } | null>(null); + const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = + useState(false); + const [hoveredTable, setHoveredTable] = useState<{ + block: Block; + element: HTMLElement; + } | null>(null); + const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); + const tableHoverTimeoutRef = useRef(null); + const [popoutTable, setPopoutTable] = useState(null); + // Viewer-specific comment popover state (global comments + code blocks) + const [viewerCommentPopover, setViewerCommentPopover] = useState<{ + anchorEl: HTMLElement; + contextText: string; + selectedText?: string; + initialText?: string; + isGlobal: boolean; + codeBlock?: { block: Block; element: HTMLElement }; + } | null>(null); + // Viewer-specific quick label state (code blocks) + const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{ + anchorEl: HTMLElement; + codeBlock: { block: Block; element: HTMLElement }; + } | null>(null); + const hoverTimeoutRef = useRef(null); + const stickySentinelRef = useRef(null); + const lastAutoScrolledHashRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + // Shared annotation infrastructure via hook + const { + highlighterRef, + toolbarState, + commentPopover: hookCommentPopover, + quickLabelPicker: hookQuickLabelPicker, + handleAnnotate, + handleQuickLabel, + handleToolbarClose, + handleRequestComment, + handleCommentSubmit: hookCommentSubmit, + handleCommentClose: hookCommentClose, + handleFloatingQuickLabel: hookFloatingQuickLabel, + handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss, + removeHighlight: hookRemoveHighlight, + clearAllHighlights, + applyAnnotations, + } = useAnnotationHighlighter({ + containerRef, + annotations, + onAddAnnotation, + onSelectAnnotation, + selectedAnnotationId, + mode, + }); - useEffect(() => { - const handleHashChange = () => { - lastAutoScrolledHashRef.current = null; - setLocationHash(window.location.hash); - }; + // Refs for code block annotation path + const onAddAnnotationRef = useRef(onAddAnnotation); + useEffect(() => { + onAddAnnotationRef.current = onAddAnnotation; + }, [onAddAnnotation]); + const modeRef = useRef(mode); + useEffect(() => { + modeRef.current = mode; + }, [mode]); + + // Pinpoint mode: hover + click to select elements + const handlePinpointCodeBlockClick = useCallback( + (blockId: string, element: HTMLElement) => { + const codeEl = element.querySelector("code"); + if (!codeEl) return; + // In pinpoint mode, apply code block annotation based on current editor mode + if (modeRef.current === "redline") { + applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION); + } else if (modeRef.current === "quickLabel") { + setCodeBlockQuickLabelPicker({ + anchorEl: element, + codeBlock: { + block: blocks.find((b) => b.id === blockId)!, + element, + }, + }); + } else { + // Show comment popover anchored to the code block + setViewerCommentPopover({ + anchorEl: element, + contextText: (codeEl.textContent || "").slice(0, 80), + selectedText: codeEl.textContent || "", + isGlobal: false, + codeBlock: { + block: blocks.find((b) => b.id === blockId)!, + element, + }, + }); + } + }, + [blocks], + ); - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, []); - - const scrollToAnchor = useCallback((hash: string) => { - const anchor = decodeAnchorHash(hash); - if (!anchor) return false; - - const container = containerRef.current; - if (!container || !stickyScrollViewport) return false; - - const target = document.getElementById(anchor); - if (!target || !container.contains(target)) return false; - - const stickyActionsEl = container.querySelector('[data-sticky-actions]'); - const stickyTop = stickyActionsEl - ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0 - : 0; - const headerOffset = stickyActionsEl - ? stickyActionsEl.getBoundingClientRect().height + stickyTop - : 0; - const containerRect = stickyScrollViewport.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); - const relativeTop = targetRect.top - containerRect.top; - const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset; - - stickyScrollViewport.scrollTo({ - top: Math.max(0, offsetPosition), - behavior: 'smooth', + const { hoverTarget } = usePinpoint({ + containerRef, + highlighterRef, + inputMethod, + enabled: + !toolbarState && + !hookCommentPopover && + !viewerCommentPopover && + !hookQuickLabelPicker && + !codeBlockQuickLabelPicker && + !(isPlanDiffActive ?? false), + onCodeBlockClick: handlePinpointCodeBlockClick, }); - return true; - }, [stickyScrollViewport]); - useEffect(() => { - if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return; - const timer = window.setTimeout(() => { - if (scrollToAnchor(locationHash)) { - lastAutoScrolledHashRef.current = locationHash; - } - }, 0); - return () => window.clearTimeout(timer); - }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]); - - // Use the native copy event so clipboard writes are synchronous (Safari - // rejects the async navigator.clipboard API outside the user-gesture window). - // web-highlighter clears the DOM selection on mouseup, so the browser has - // nothing to copy by the time Cmd+C fires — we inject the captured text here. - useEffect(() => { - const handleCopy = (e: ClipboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; + // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile) + useEffect(() => { + const container = containerRef.current; + if (!container || !isTouchDevice) return; - if (toolbarState?.selectionText) { + const handleContextMenu = (e: Event) => { e.preventDefault(); - e.clipboardData?.setData('text/plain', toolbarState.selectionText); - } - }; + }; - document.addEventListener('copy', handleCopy); - return () => document.removeEventListener('copy', handleCopy); - }, [toolbarState]); - - // Imperative handle — delegates to hook, extends removeHighlight for code blocks - useImperativeHandle(ref, () => ({ - removeHighlight: (id: string) => { - // Code block annotations need syntax re-highlighting after removal. - // Must run BEFORE hookRemoveHighlight, which removes the elements. - const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`); - manualHighlights?.forEach(el => { - const parent = el.parentNode; - if (parent && parent.nodeName === 'CODE') { - const codeEl = parent as HTMLElement; - const plainText = el.textContent || ''; - el.remove(); - codeEl.textContent = plainText; - const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id')); - codeEl.removeAttribute('data-highlighted'); - codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`; - hljs.highlightElement(codeEl); - } - }); + container.addEventListener("contextmenu", handleContextMenu); + return () => + container.removeEventListener("contextmenu", handleContextMenu); + }, []); + + // Detect when sticky action bar is "stuck" to show card background. + // The IntersectionObserver root must be the actual scroll element — the + // OverlayScrollArea viewport — not the
host, which doesn't scroll. + const stickyScrollViewport = useScrollViewport(); + useEffect(() => { + if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) + return; + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { root: stickyScrollViewport, threshold: 0 }, + ); + observer.observe(stickySentinelRef.current); + return () => observer.disconnect(); + }, [stickyActions, stickyScrollViewport]); + + useEffect(() => { + const handleHashChange = () => { + lastAutoScrolledHashRef.current = null; + setLocationHash(window.location.hash); + }; - hookRemoveHighlight(id); - }, - clearAllHighlights, - applySharedAnnotations: applyAnnotations, - }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]); - - // --- Viewer-specific: code block annotation --- - - const applyCodeBlockAnnotation = ( - blockId: string, - codeEl: Element, - type: AnnotationType, - text?: string, - images?: ImageAttachment[], - isQuickLabel?: boolean, - quickLabelTip?: string, - ) => { - const id = `codeblock-${Date.now()}`; - const codeText = codeEl.textContent || ''; - - const wrapper = document.createElement('mark'); - wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim(); - wrapper.dataset.bindId = id; - wrapper.textContent = codeText; - - codeEl.innerHTML = ''; - codeEl.appendChild(wrapper); - - const newAnnotation: Annotation = { - id, - blockId, - startOffset: 0, - endOffset: codeText.length, - type, - text, - originalText: codeText, - createdA: Date.now(), - author: getIdentity(), - images, - ...(isQuickLabel ? { isQuickLabel: true } : {}), - ...(quickLabelTip ? { quickLabelTip } : {}), - }; + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); + + const scrollToAnchor = useCallback( + (hash: string) => { + const anchor = decodeAnchorHash(hash); + if (!anchor) return false; + + const container = containerRef.current; + if (!container || !stickyScrollViewport) return false; + + const target = document.getElementById(anchor); + if (!target || !container.contains(target)) return false; + + const stickyActionsEl = container.querySelector( + "[data-sticky-actions]", + ); + const stickyTop = stickyActionsEl + ? Number.parseFloat( + window.getComputedStyle(stickyActionsEl).top || "0", + ) || 0 + : 0; + const headerOffset = stickyActionsEl + ? stickyActionsEl.getBoundingClientRect().height + stickyTop + : 0; + const containerRect = stickyScrollViewport.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const relativeTop = targetRect.top - containerRect.top; + const offsetPosition = + stickyScrollViewport.scrollTop + relativeTop - headerOffset; + + stickyScrollViewport.scrollTo({ + top: Math.max(0, offsetPosition), + behavior: "smooth", + }); + return true; + }, + [stickyScrollViewport], + ); + + useEffect(() => { + if ( + !stickyScrollViewport || + !locationHash || + lastAutoScrolledHashRef.current === locationHash + ) + return; + const timer = window.setTimeout(() => { + if (scrollToAnchor(locationHash)) { + lastAutoScrolledHashRef.current = locationHash; + } + }, 0); + return () => window.clearTimeout(timer); + }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]); + + // Use the native copy event so clipboard writes are synchronous (Safari + // rejects the async navigator.clipboard API outside the user-gesture window). + // web-highlighter clears the DOM selection on mouseup, so the browser has + // nothing to copy by the time Cmd+C fires — we inject the captured text here. + useEffect(() => { + const handleCopy = (e: ClipboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if (toolbarState?.selectionText) { + e.preventDefault(); + e.clipboardData?.setData("text/plain", toolbarState.selectionText); + } + }; - onAddAnnotationRef.current(newAnnotation); - window.getSelection()?.removeAllRanges(); - }; - - const handleCodeBlockAnnotate = (type: AnnotationType) => { - if (!hoveredCodeBlock) return; - const codeEl = hoveredCodeBlock.element.querySelector('code'); - if (!codeEl) return; - applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type); - setHoveredCodeBlock(null); - }; - - const handleCodeBlockQuickLabel = (label: QuickLabel) => { - if (!hoveredCodeBlock) return; - const codeEl = hoveredCodeBlock.element.querySelector('code'); - if (!codeEl) return; - applyCodeBlockAnnotation( - hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true, label.tip + document.addEventListener("copy", handleCopy); + return () => document.removeEventListener("copy", handleCopy); + }, [toolbarState]); + + // Imperative handle — delegates to hook, extends removeHighlight for code blocks + useImperativeHandle( + ref, + () => ({ + removeHighlight: (id: string) => { + // Code block annotations need syntax re-highlighting after removal. + // Must run BEFORE hookRemoveHighlight, which removes the elements. + const manualHighlights = containerRef.current?.querySelectorAll( + `[data-bind-id="${id}"]`, + ); + manualHighlights?.forEach((el) => { + const parent = el.parentNode; + if (parent && parent.nodeName === "CODE") { + const codeEl = parent as HTMLElement; + const plainText = el.textContent || ""; + el.remove(); + codeEl.textContent = plainText; + const block = blocks.find( + (b) => + b.id === + codeEl + .closest("[data-block-id]") + ?.getAttribute("data-block-id"), + ); + codeEl.removeAttribute("data-highlighted"); + codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ""}`; + hljs.highlightElement(codeEl); + } + }); + + hookRemoveHighlight(id); + }, + clearAllHighlights, + applySharedAnnotations: applyAnnotations, + }), + [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks], ); - setHoveredCodeBlock(null); - }; - - const handleCodeBlockToolbarClose = () => { - setHoveredCodeBlock(null); - }; - - // Viewer-specific comment popover handlers (code blocks + global comments) - - const handleCodeBlockRequestComment = (initialChar?: string) => { - if (!hoveredCodeBlock) return; - const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || ''; - setViewerCommentPopover({ - anchorEl: hoveredCodeBlock.element, - contextText: codeText.slice(0, 80), - selectedText: codeText, - initialText: initialChar, - isGlobal: false, - codeBlock: hoveredCodeBlock, - }); - setHoveredCodeBlock(null); - }; - const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => { - if (!viewerCommentPopover) return; + // --- Viewer-specific: code block annotation --- + + const applyCodeBlockAnnotation = ( + blockId: string, + codeEl: Element, + type: AnnotationType, + text?: string, + images?: ImageAttachment[], + isQuickLabel?: boolean, + quickLabelTip?: string, + ) => { + const id = `codeblock-${Date.now()}`; + const codeText = codeEl.textContent || ""; + + const wrapper = document.createElement("mark"); + wrapper.className = + `annotation-highlight ${type === AnnotationType.DELETION ? "deletion" : type === AnnotationType.COMMENT ? "comment" : ""}`.trim(); + wrapper.dataset.bindId = id; + wrapper.textContent = codeText; + + codeEl.innerHTML = ""; + codeEl.appendChild(wrapper); - if (viewerCommentPopover.isGlobal) { const newAnnotation: Annotation = { - id: `global-${Date.now()}`, - blockId: '', + id, + blockId, startOffset: 0, - endOffset: 0, - type: AnnotationType.GLOBAL_COMMENT, - text: text.trim(), - originalText: '', + endOffset: codeText.length, + type, + text, + originalText: codeText, createdA: Date.now(), author: getIdentity(), images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), }; - onAddAnnotation(newAnnotation); - } else if (viewerCommentPopover.codeBlock) { - const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code'); - if (codeEl) { - applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images); + + onAddAnnotationRef.current(newAnnotation); + window.getSelection()?.removeAllRanges(); + }; + + const handleCodeBlockAnnotate = (type: AnnotationType) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector("code"); + if (!codeEl) return; + applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type); + setHoveredCodeBlock(null); + }; + + const handleCodeBlockQuickLabel = (label: QuickLabel) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector("code"); + if (!codeEl) return; + applyCodeBlockAnnotation( + hoveredCodeBlock.block.id, + codeEl, + AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, + undefined, + true, + label.tip, + ); + setHoveredCodeBlock(null); + }; + + const handleCodeBlockToolbarClose = () => { + setHoveredCodeBlock(null); + }; + + // Viewer-specific comment popover handlers (code blocks + global comments) + + const handleCodeBlockRequestComment = (initialChar?: string) => { + if (!hoveredCodeBlock) return; + const codeText = + hoveredCodeBlock.element.querySelector("code")?.textContent || ""; + setViewerCommentPopover({ + anchorEl: hoveredCodeBlock.element, + contextText: codeText.slice(0, 80), + selectedText: codeText, + initialText: initialChar, + isGlobal: false, + codeBlock: hoveredCodeBlock, + }); + setHoveredCodeBlock(null); + }; + + const handleViewerCommentSubmit = ( + text: string, + images?: ImageAttachment[], + ) => { + if (!viewerCommentPopover) return; + + if (viewerCommentPopover.isGlobal) { + const newAnnotation: Annotation = { + id: `global-${Date.now()}`, + blockId: "", + startOffset: 0, + endOffset: 0, + type: AnnotationType.GLOBAL_COMMENT, + text: text.trim(), + originalText: "", + createdA: Date.now(), + author: getIdentity(), + images, + }; + onAddAnnotation(newAnnotation); + } else if (viewerCommentPopover.codeBlock) { + const codeEl = + viewerCommentPopover.codeBlock.element.querySelector("code"); + if (codeEl) { + applyCodeBlockAnnotation( + viewerCommentPopover.codeBlock.block.id, + codeEl, + AnnotationType.COMMENT, + text, + images, + ); + } } - } - setViewerCommentPopover(null); - }; + setViewerCommentPopover(null); + }; - const handleViewerCommentClose = useCallback(() => { - setViewerCommentPopover(null); - }, []); + const handleViewerCommentClose = useCallback(() => { + setViewerCommentPopover(null); + }, []); + + const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + + return ( + +
+ {taterMode && } +
+ {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */} + {(repoInfo || + hasPreviousVersion || + showDemoBadge || + linkedDocInfo || + archiveInfo || + sourceInfo) && ( +
+ +
+ )} - const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + {/* Sentinel for sticky detection */} + {stickyActions && ( +
+ + {/* Image lightbox */} + {lightbox && + createPortal( + setLightbox(null)} + />, + document.body, + )} +
+
+ ); + }, +); /** Simple lightbox overlay for enlarged image viewing. */ -const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => { +const ImageLightbox: React.FC<{ + src: string; + alt: string; + onClose: () => void; +}> = ({ src, alt, onClose }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === "Escape") onClose(); }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); return ( @@ -931,37 +1196,38 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> onClick={(e) => e.stopPropagation()} /> {alt && ( -
{alt}
+
+ {alt} +
)}
); }; - - - - /** Groups consecutive list-item blocks so they can share a pinpoint hover wrapper. */ type RenderGroup = - | { type: 'single'; block: Block } - | { type: 'list-group'; blocks: Block[]; key: string }; + | { type: "single"; block: Block } + | { type: "list-group"; blocks: Block[]; key: string }; function groupBlocks(blocks: Block[]): RenderGroup[] { const groups: RenderGroup[] = []; let i = 0; while (i < blocks.length) { - if (blocks[i].type === 'list-item') { + if (blocks[i].type === "list-item") { const listBlocks: Block[] = []; - while (i < blocks.length && blocks[i].type === 'list-item') { + while (i < blocks.length && blocks[i].type === "list-item") { listBlocks.push(blocks[i]); i++; } - groups.push({ type: 'list-group', blocks: listBlocks, key: `list-${listBlocks[0].id}` }); + groups.push({ + type: "list-group", + blocks: listBlocks, + key: `list-${listBlocks[0].id}`, + }); } else { - groups.push({ type: 'single', block: blocks[i] }); + groups.push({ type: "single", block: blocks[i] }); i++; } } return groups; } - diff --git a/packages/ui/utils/permissionMode.test.ts b/packages/ui/utils/permissionMode.test.ts new file mode 100644 index 000000000..9a18b39cd --- /dev/null +++ b/packages/ui/utils/permissionMode.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { PERMISSION_MODE_OPTIONS } from "./permissionMode"; + +describe("permission mode options", () => { + test("includes all supported permission modes shown in Settings", () => { + expect(PERMISSION_MODE_OPTIONS.map((option) => option.value)).toEqual([ + "acceptEdits", + "auto", + "bypassPermissions", + "default", + "deferNative", + ]); + }); +}); diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 46bfa61d1..bdda8cb44 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -11,52 +11,73 @@ * - default: Manually approve each tool call */ -import { storage } from './storage'; +import { storage } from "./storage"; -const STORAGE_KEY_MODE = 'plannotator-permission-mode'; -const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; +const STORAGE_KEY_MODE = "plannotator-permission-mode"; +const STORAGE_KEY_CONFIGURED = "plannotator-permission-mode-configured"; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'auto' | 'default'; +export type PermissionMode = + | "bypassPermissions" + | "acceptEdits" + | "auto" + | "default" + | "deferNative"; export interface PermissionModeSettings { mode: PermissionMode; configured: boolean; // Whether user has explicitly set this } -export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; description: string }[] = [ +export const PERMISSION_MODE_OPTIONS: { + value: PermissionMode; + label: string; + description: string; +}[] = [ { - value: 'acceptEdits', - label: 'Auto-accept Edits', - description: 'Auto-approve file edits, ask for other tools', + value: "acceptEdits", + label: "Auto-accept Edits", + description: "Auto-approve file edits, ask for other tools", }, { - value: 'auto', - label: 'Auto Mode', - description: 'Autonomous execution with a safety classifier (requires Claude Code 2026-03+ and Sonnet 4.6+)', + value: "auto", + label: "Auto Mode", + description: + "Autonomous execution with a safety classifier (requires Claude Code 2026-03+ and Sonnet 4.6+)", }, { - value: 'bypassPermissions', - label: 'Bypass Permissions', - description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', + value: "bypassPermissions", + label: "Bypass Permissions", + description: + "Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)", }, { - value: 'default', - label: 'Manual Approval', - description: 'Manually approve each tool call', + value: "default", + label: "Manual Approval", + description: "Manually approve each tool call", + }, + { + value: "deferNative", + label: "Clear context + bypass (native)", + description: + "Hand approval to Claude Code's own menu, which offers clear-context + bypass (needs showClearContextOnPlanAccept).", }, ]; -const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +const DEFAULT_MODE: PermissionMode = "acceptEdits"; + +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; - const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; + const mode = storage.getItem(STORAGE_KEY_MODE); + const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === "true"; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } @@ -66,12 +87,12 @@ export function getPermissionModeSettings(): PermissionModeSettings { */ export function savePermissionModeSettings(mode: PermissionMode): void { storage.setItem(STORAGE_KEY_MODE, mode); - storage.setItem(STORAGE_KEY_CONFIGURED, 'true'); + storage.setItem(STORAGE_KEY_CONFIGURED, "true"); } /** * Check if the user needs to configure their permission mode preference */ export function needsPermissionModeSetup(): boolean { - return storage.getItem(STORAGE_KEY_CONFIGURED) !== 'true'; + return storage.getItem(STORAGE_KEY_CONFIGURED) !== "true"; }