diff --git a/.gitignore b/.gitignore index 999c66367..e13177e54 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ plannotator-local # Local Pi state/memory (not upstream) /.pi/ + +# @plannotator/ui CSS build artifacts (generated by prepublishOnly — not committed) +packages/ui/styles.css +packages/ui/styles.js diff --git a/AGENTS.md b/AGENTS.md index f6b84eea4..b564799fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. +> **Reusing the document UI (theme / markdown / editor / settings / comments / layout) in the commercial Workspaces app? Read `packages/ui/README.md` FIRST.** It explains the published `@plannotator/ui` + `@plannotator/core` packages and the host-override seams a host plugs its own backend into via `configurePlannotatorUI()`. A prior from-scratch reimplementation of this UI broke the app and was reverted — do **not** rebuild it or recreate `packages/document-ui`. Add a seam to `@plannotator/ui` instead, keep Plannotator's app unchanged, and never delete working code until a human confirms parity in the browser. + ## Project Structure ``` @@ -81,7 +83,8 @@ plannotator/ │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) -│ ├── shared/ # Shared types, utilities, and cross-runtime logic +│ ├── core/ # @plannotator/core — browser-safe, zero-dep universal slice (pure utils + types) shared by ui + shared; published so @plannotator/ui can be installed standalone. `shared` re-exports the moved modules via one-line shims so Plannotator is unchanged. +│ ├── shared/ # Node/git/server logic + cross-runtime types (re-exports browser-safe modules from @plannotator/core) │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) │ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) @@ -194,7 +197,7 @@ Approve → "LGTM" sent to agent session ## Ask AI Provider Defaults -Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`: +Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/core/agents.ts` (re-exported via the `packages/shared/agents.ts` shim) and is applied by `packages/ui/utils/aiProvider.ts`: | Origin | Preferred Ask AI provider | |--------|---------------------------| diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index c6f71aef2..3df7c1fb8 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,29 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs agent-terminal worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps review-profiles; do +# Modules that MOVED to @plannotator/core — vendor the real impl from core. +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Node-bound shared modules that now import types from @plannotator/core/*-types — +# vendor from shared, rewrite the bare core specifier to the flat relative path. +for f in config storage workspace-status; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/\\([^'\"]*\\)-types['\"]|from './\\1-types.js'|g" \ + > "generated/$f.ts" +done + +# Extracted type files those node-bound modules now depend on — vendor from core. +for f in config-types storage-types workspace-status-types; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Everything else in the original flat list stays sourced from packages/shared. +for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node review-profiles; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done @@ -40,9 +62,15 @@ for f in tour-review; do > "generated/$f.ts" done +# Vendor the moved AI context types from core into generated/ai/. +printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ + | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" + for f in index types provider session-manager endpoints context base-session; do src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/ai-context['\"]|from './ai-context.js'|g" \ + > "generated/ai/$f.ts" done for f in claude-agent-sdk codex-sdk opencode-sdk command-path pi-sdk pi-sdk-node pi-events; do diff --git a/bun.lock b/bun.lock index f49daa850..fab1801fa 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,16 @@ "packages/ai": { "name": "@plannotator/ai", "version": "0.0.1", + "dependencies": { + "@plannotator/core": "workspace:*", + }, + }, + "packages/core": { + "name": "@plannotator/core", + "version": "0.21.1", + "devDependencies": { + "typescript": "~5.8.2", + }, }, "packages/editor": { "name": "@plannotator/editor", @@ -235,13 +245,14 @@ "version": "0.0.1", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", + "@plannotator/core": "workspace:*", "parse5": "^7.3.0", "turndown": "^7.2.4", }, }, "packages/ui": { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.21.1", "dependencies": { "@atomic-editor/editor": "^0.4.3", "@codemirror/autocomplete": "^6.20.3", @@ -261,9 +272,8 @@ "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", "@pierre/diffs": "1.2.8", - "@plannotator/ai": "workspace:*", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "0.1.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -275,26 +285,35 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", - "react": "^19.2.3", - "react-dom": "^19.2.3", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", "unique-username-generator": "^1.5.1", }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", + "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2", + "vite": "^6.2.0", + }, + "peerDependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", }, }, }, @@ -797,6 +816,8 @@ "@plannotator/ai": ["@plannotator/ai@workspace:packages/ai"], + "@plannotator/core": ["@plannotator/core@workspace:packages/core"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], diff --git a/package.json b/package.json index e532c5da9..01ce0824a 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", + "build:ui-css": "bun run --cwd packages/ui build:css" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/ai/package.json b/packages/ai/package.json index 7cd03cac6..a72346f5a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -17,5 +17,7 @@ "./providers/opencode-sdk": "./providers/opencode-sdk.ts", "./providers/pi-sdk-node": "./providers/pi-sdk-node.ts" }, - "dependencies": {} + "dependencies": { + "@plannotator/core": "workspace:*" + } } diff --git a/packages/ai/types.ts b/packages/ai/types.ts index b87e79f8f..e9c5ab424 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -10,82 +10,8 @@ // Context — what the AI session knows about // --------------------------------------------------------------------------- -/** The surface the user is interacting with when they invoke AI. */ -export type AIContextMode = "plan-review" | "code-review" | "annotate"; - -/** - * Describes the parent agent session that originally produced the plan or diff. - * Used to fork conversations with full history. - */ -export interface ParentSession { - /** Session ID from the host agent (e.g. Claude Code session UUID). */ - sessionId: string; - /** Working directory the parent session was running in. */ - cwd: string; -} - -/** - * Snapshot of plan-review-specific context. - * Passed when AIContextMode is "plan-review". - */ -export interface PlanContext { - /** The full plan markdown as submitted by the agent. */ - plan: string; - /** Previous plan version (if this is a resubmission). */ - previousPlan?: string; - /** The version number in the plan's history. */ - version?: number; - /** Total number of versions in the plan's history. */ - totalVersions?: number; - /** Project/repository label used for plan history. */ - project?: string; - /** Annotations the user has made so far (serialised for the prompt). */ - annotations?: string; -} - -/** - * Snapshot of code-review-specific context. - * Passed when AIContextMode is "code-review". - */ -export interface CodeReviewContext { - /** The unified diff patch. */ - patch: string; - /** The specific file being discussed (if scoped). */ - filePath?: string; - /** The line range being discussed (if scoped). */ - lineRange?: { start: number; end: number; side: "old" | "new" }; - /** The code snippet being discussed (if scoped). */ - selectedCode?: string; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Snapshot of annotate-mode context. - * Passed when AIContextMode is "annotate". - */ -export interface AnnotateContext { - /** The markdown file content being annotated. */ - content: string; - /** Path to the file on disk. */ - filePath: string; - /** Source attribution shown in the UI, such as an original URL or filename. */ - sourceInfo?: string; - /** True when the document was converted from HTML or a remote reader result. */ - sourceConverted?: boolean; - /** Render mode for the annotated content. */ - renderAs?: "markdown" | "html"; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Union of mode-specific contexts, discriminated by `mode`. - */ -export type AIContext = - | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } - | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } - | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; +import type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; +export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession }; // --------------------------------------------------------------------------- // Messages — what streams back from the AI diff --git a/packages/core/agent-jobs.ts b/packages/core/agent-jobs.ts new file mode 100644 index 000000000..a97e980f9 --- /dev/null +++ b/packages/core/agent-jobs.ts @@ -0,0 +1,149 @@ +/** + * Agent Jobs — shared types, state machine, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and (future) Node handler import + * this module and wrap it with their respective HTTP transport layers. + * + * Mirrors packages/shared/external-annotation.ts in structure. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; + +/** + * Snapshot of the diff the reviewer was looking at when this job was launched. + * Carried on the job so downstream UIs (agent-result panel "Copy All") export + * the same `**Diff:** ...` header the job was actually run against — if the + * reviewer switches the UI to a different diff afterwards, the job's snapshot + * still reflects truth. Structurally compatible with the UI-side + * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. + */ +export interface AgentJobDiffContext { + mode: string; + base?: string; + worktreePath?: string | null; +} + +export interface AgentJobInfo { + /** Unique job identifier (UUID). */ + id: string; + /** Source identifier for external annotations — "agent-{id prefix}". */ + source: string; + /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ + provider: string; + /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ + engine?: string; + /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ + model?: string; + /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ + effort?: string; + /** Codex reasoning effort level (e.g., "high", "medium"). */ + reasoningEffort?: string; + /** Whether Codex fast mode (service_tier=fast) was enabled. */ + fastMode?: boolean; + /** Human-readable label for the job. */ + label: string; + /** Current lifecycle status. */ + status: AgentJobStatus; + /** Timestamp when the job was created. */ + startedAt: number; + /** Timestamp when the job reached a terminal state. */ + endedAt?: number; + /** Process exit code (set on done/failed). */ + exitCode?: number; + /** Last ~500 chars of stderr on failure. */ + error?: string; + /** The actual command that was spawned (for display/debug). */ + command: string[]; + /** Working directory where the process was spawned. */ + cwd?: string; + /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ + prompt?: string; + /** Review summary set by the agent on completion. */ + summary?: { + correctness: string; + explanation: string; + confidence: number; + }; + /** PR URL at launch time — used to attribute findings to the correct PR. */ + prUrl?: string; + /** PR diff scope at launch time — "layer" or "full-stack". */ + diffScope?: string; + /** Diff context at launch time (see AgentJobDiffContext). */ + diffContext?: AgentJobDiffContext; + /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ + reviewProfileId?: string; + /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ + reviewProfileLabel?: string; +} + +export interface AgentCapability { + id: string; + name: string; + available: boolean; +} + +export interface AgentCapabilities { + mode: "plan" | "review" | "annotate"; + providers: AgentCapability[]; + /** True if at least one provider is available. */ + available: boolean; +} + +// --------------------------------------------------------------------------- +// SSE event types +// --------------------------------------------------------------------------- + +export type AgentJobEvent = + | { type: "snapshot"; jobs: AgentJobInfo[] } + | { type: "job:started"; job: AgentJobInfo } + | { type: "job:updated"; job: AgentJobInfo } + | { type: "job:completed"; job: AgentJobInfo } + | { type: "job:log"; jobId: string; delta: string } + | { type: "jobs:cleared" }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeAgentSSEEvent(event: AgentJobEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check if a status is terminal (no further transitions). */ +export function isTerminalStatus(status: AgentJobStatus): boolean { + return status === "done" || status === "failed" || status === "killed"; +} + +/** Generate the source identifier for a job from its ID. */ +export function jobSource(id: string): string { + return "agent-" + id.slice(0, 8); +} + +// --------------------------------------------------------------------------- +// Review ingestion completion semantics +// --------------------------------------------------------------------------- + +/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ +export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; + +/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ +export function markJobReviewFailed(job: AgentJobInfo, error: string): void { + job.status = "failed"; + job.error = error; +} diff --git a/packages/shared/agent-terminal.test.ts b/packages/core/agent-terminal.test.ts similarity index 100% rename from packages/shared/agent-terminal.test.ts rename to packages/core/agent-terminal.test.ts diff --git a/packages/core/agent-terminal.ts b/packages/core/agent-terminal.ts new file mode 100644 index 000000000..42f8339a3 --- /dev/null +++ b/packages/core/agent-terminal.ts @@ -0,0 +1,53 @@ +export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; + +export function buildAgentTerminalWsPath(token: string): string { + if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { + throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); + } + return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; +} + +export function isAgentTerminalWsRoute(pathname: string): boolean { + return pathname === AGENT_TERMINAL_WS_BASE_PATH || + pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); +} + +export type AgentTerminalDisabledReason = + | "not-annotate-mode" + | "remote-disabled" + | "runtime-unavailable" + | "webtui-unavailable" + | "pty-unavailable" + | "unsupported-runtime"; + +export type AgentTerminalAgent = { + id: string; + name: string; + available: boolean; +}; + +export type AgentTerminalCapability = + | { + enabled: true; + cwd: string; + wsPath: string; + agents: AgentTerminalAgent[]; + } + | { + enabled: false; + reason: AgentTerminalDisabledReason; + message?: string; + }; + +export type AnnotateAgentTerminalMode = + | "annotate" + | "annotate-last" + | "annotate-folder" + | string + | undefined; + +export function supportsAnnotateAgentTerminalMode( + mode: AnnotateAgentTerminalMode, +): boolean { + return mode === "annotate" || mode === "annotate-folder"; +} diff --git a/packages/core/agents.ts b/packages/core/agents.ts new file mode 100644 index 000000000..cc9994135 --- /dev/null +++ b/packages/core/agents.ts @@ -0,0 +1,53 @@ +/** + * Centralized agent configuration — single source of truth for all supported agents. + * + * To add a new agent: + * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, + * optional AI provider types) + * 2. If detection is via environment variable, add it to the detection chain + * in apps/hook/server/index.ts (detectedOrigin constant) + * 3. That's it — all UI components read from this config automatically + */ + +type AgentConfigEntry = { + name: string; + badge: string; + /** AI provider type(s) that naturally match this origin, in preference order. */ + aiProviderTypes?: readonly string[]; +}; + +export const AGENT_CONFIG = { + 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, + 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, + 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, + 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, + 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, + 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, + 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, + 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, + 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, +} as const satisfies Record; + +/** All recognized origin values. */ +export type Origin = keyof typeof AGENT_CONFIG; + +/** Resolve an origin to a human-readable agent name. */ +export function getAgentName(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; + return 'Coding Agent'; +} + +/** Resolve an origin to Tailwind badge classes. */ +export function getAgentBadge(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; + return 'bg-zinc-500/20 text-zinc-400'; +} + +/** Resolve an origin to matching AI provider types, in preference order. */ +export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { + if (origin && origin in AGENT_CONFIG) { + const config = AGENT_CONFIG[origin as Origin]; + return 'aiProviderTypes' in config ? config.aiProviderTypes : []; + } + return []; +} diff --git a/packages/core/ai-context.ts b/packages/core/ai-context.ts new file mode 100644 index 000000000..57e2f8bb9 --- /dev/null +++ b/packages/core/ai-context.ts @@ -0,0 +1,76 @@ +/** The surface the user is interacting with when they invoke AI. */ +export type AIContextMode = "plan-review" | "code-review" | "annotate"; + +/** + * Describes the parent agent session that originally produced the plan or diff. + * Used to fork conversations with full history. + */ +export interface ParentSession { + /** Session ID from the host agent (e.g. Claude Code session UUID). */ + sessionId: string; + /** Working directory the parent session was running in. */ + cwd: string; +} + +/** + * Snapshot of plan-review-specific context. + * Passed when AIContextMode is "plan-review". + */ +export interface PlanContext { + /** The full plan markdown as submitted by the agent. */ + plan: string; + /** Previous plan version (if this is a resubmission). */ + previousPlan?: string; + /** The version number in the plan's history. */ + version?: number; + /** Total number of versions in the plan's history. */ + totalVersions?: number; + /** Project/repository label used for plan history. */ + project?: string; + /** Annotations the user has made so far (serialised for the prompt). */ + annotations?: string; +} + +/** + * Snapshot of code-review-specific context. + * Passed when AIContextMode is "code-review". + */ +export interface CodeReviewContext { + /** The unified diff patch. */ + patch: string; + /** The specific file being discussed (if scoped). */ + filePath?: string; + /** The line range being discussed (if scoped). */ + lineRange?: { start: number; end: number; side: "old" | "new" }; + /** The code snippet being discussed (if scoped). */ + selectedCode?: string; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Snapshot of annotate-mode context. + * Passed when AIContextMode is "annotate". + */ +export interface AnnotateContext { + /** The markdown file content being annotated. */ + content: string; + /** Path to the file on disk. */ + filePath: string; + /** Source attribution shown in the UI, such as an original URL or filename. */ + sourceInfo?: string; + /** True when the document was converted from HTML or a remote reader result. */ + sourceConverted?: boolean; + /** Render mode for the annotated content. */ + renderAs?: "markdown" | "html"; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Union of mode-specific contexts, discriminated by `mode`. + */ +export type AIContext = + | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } + | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } + | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; diff --git a/packages/core/browser-paths.ts b/packages/core/browser-paths.ts new file mode 100644 index 000000000..ce867a505 --- /dev/null +++ b/packages/core/browser-paths.ts @@ -0,0 +1,25 @@ +export function normalizeBrowserPath(path: string): string { + const withForwardSlashes = path.replace(/\\/g, "/"); + const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; + const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); + if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; + return collapsed.replace(/\/+$/, ""); +} + +export function dirnameBrowserPath(path: string): string { + const normalized = normalizeBrowserPath(path); + const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); + if (driveRootMatch) return `${driveRootMatch[1]}/`; + const index = normalized.lastIndexOf("/"); + if (index < 0) return normalized; + if (index === 0) return "/"; + return normalized.slice(0, index); +} + +export function pathIsInsideDir(path: string, dir: string): boolean { + const normalizedPath = normalizeBrowserPath(path); + const normalizedDir = normalizeBrowserPath(dir); + if (!normalizedDir) return normalizedPath === ""; + const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; + return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); +} diff --git a/packages/shared/code-file.test.ts b/packages/core/code-file.test.ts similarity index 100% rename from packages/shared/code-file.test.ts rename to packages/core/code-file.test.ts diff --git a/packages/core/code-file.ts b/packages/core/code-file.ts new file mode 100644 index 000000000..43633a766 --- /dev/null +++ b/packages/core/code-file.ts @@ -0,0 +1,41 @@ +export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; + +export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; + +const IMPLAUSIBLE_CHARS = /[{},*?\s]/; + +export function isPlausibleCodeFilePath(input: string): boolean { + return !IMPLAUSIBLE_CHARS.test(input); +} + +export interface ParsedCodePath { + filePath: string; + line?: number; + lineEnd?: number; +} + +const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; + +export function parseCodePath(input: string): ParsedCodePath { + const clean = input.replace(/#.*$/, ''); + const m = clean.match(LINE_SUFFIX_RE); + if (!m) return { filePath: clean }; + let line = Number.parseInt(m[1], 10); + let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } + return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; +} + +export function stripLineRef(input: string): string { + return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); +} + +export function isCodeFilePath(input: string): boolean { + if (!isPlausibleCodeFilePath(input)) return false; + return CODE_FILE_REGEX.test(stripLineRef(input)) + && !input.startsWith('http://') && !input.startsWith('https://'); +} + +export function isCodeFilePathStrict(input: string): boolean { + return input.includes('/') && isCodeFilePath(input); +} diff --git a/packages/core/compress.ts b/packages/core/compress.ts new file mode 100644 index 000000000..70c5099ac --- /dev/null +++ b/packages/core/compress.ts @@ -0,0 +1,51 @@ +/** + * Portable deflate-raw + base64url compression. + * + * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works + * in browsers, Bun, and edge runtimes. Both @plannotator/server and + * @plannotator/ui import from here — single source of truth. + */ + +export async function compress(data: unknown): Promise { + const json = JSON.stringify(data); + const byteArray = new TextEncoder().encode(json); + + const stream = new CompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const compressed = new Uint8Array(buffer); + + // Loop instead of spread to avoid RangeError on large payloads + // (String.fromCharCode(...arr) has a ~65K argument limit) + let binary = ''; + for (let i = 0; i < compressed.length; i++) { + binary += String.fromCharCode(compressed[i]); + } + const base64 = btoa(binary); + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export async function decompress(b64: string): Promise { + const base64 = b64 + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const binary = atob(base64); + const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); + + const stream = new DecompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const json = new TextDecoder().decode(buffer); + + return JSON.parse(json); +} diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts new file mode 100644 index 000000000..7d32ec769 --- /dev/null +++ b/packages/core/config-types.ts @@ -0,0 +1,18 @@ +export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; + +export interface DiffOptions { + diffStyle?: 'split' | 'unified'; + overflow?: 'scroll' | 'wrap'; + diffIndicators?: 'bars' | 'classic' | 'none'; + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; + showLineNumbers?: boolean; + showDiffBackground?: boolean; + fontFamily?: string; + fontSize?: string; + tabSize?: number; + hideWhitespace?: boolean; + expandUnchanged?: boolean; + defaultDiffType?: DefaultDiffType; + lineBgIntensity?: DiffLineBgIntensity; +} diff --git a/packages/shared/crypto.test.ts b/packages/core/crypto.test.ts similarity index 100% rename from packages/shared/crypto.test.ts rename to packages/core/crypto.test.ts diff --git a/packages/core/crypto.ts b/packages/core/crypto.ts new file mode 100644 index 000000000..0161e6dcd --- /dev/null +++ b/packages/core/crypto.ts @@ -0,0 +1,97 @@ +/** + * AES-256-GCM encryption for zero-knowledge paste storage. + * + * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. + * The key never leaves the client; it lives in the URL fragment. + */ + +/** + * Encrypt a compressed base64url string with a fresh AES-256-GCM key. + * + * Returns { ciphertext, key } where: + * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) + * - key: base64url-encoded 256-bit key for the URL fragment + */ +export async function encrypt( + compressedData: string +): Promise<{ ciphertext: string; key: string }> { + const cryptoKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(compressedData); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + plaintext + ); + + // Prepend IV to ciphertext (IV || ciphertext+tag) + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); + + return { + ciphertext: bytesToBase64url(combined), + key: bytesToBase64url(new Uint8Array(rawKey)), + }; +} + +/** + * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. + * + * Expects ciphertext format: base64url(IV || encrypted+tag) + * Returns the original compressed base64url string. + */ +export async function decrypt( + ciphertext: string, + key: string +): Promise { + const combined = base64urlToBytes(ciphertext); + const rawKey = base64urlToBytes(key); + + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey.buffer as ArrayBuffer, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); +} + +// --- Helpers --- + +function bytesToBase64url(bytes: Uint8Array): string { + // Loop to avoid RangeError on large payloads (same approach as compress.ts) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function base64urlToBytes(b64: string): Uint8Array { + const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} diff --git a/packages/core/external-annotation.ts b/packages/core/external-annotation.ts new file mode 100644 index 000000000..2260e3f87 --- /dev/null +++ b/packages/core/external-annotation.ts @@ -0,0 +1,455 @@ +/** + * External Annotations — shared types, store logic, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and Pi server handler import this module + * and wrap it with their respective HTTP transport layers. + * + * The store is generic — plan servers store Annotation objects, + * review servers store CodeAnnotation objects. The mode-specific + * input transformers handle validation and field assignment. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Constraint for any annotation type the store can hold. */ +export type StorableAnnotation = { id: string; source?: string }; + +export type ExternalAnnotationEvent = + | { type: "snapshot"; annotations: T[] } + | { type: "add"; annotations: T[] } + | { type: "remove"; ids: string[] } + | { type: "clear"; source?: string } + | { type: "update"; id: string; annotation: T }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeSSEEvent(event: ExternalAnnotationEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Input validation — shared helpers +// --------------------------------------------------------------------------- + +export interface ParseError { + error: string; +} + +/** + * Unwrap a POST body into an array of raw input objects. + * + * Accepts either: + * - A single annotation object: `{ source: "...", ... }` + * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` + */ +function unwrapBody(body: unknown): Record[] | ParseError { + if (!body || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + // Batch format: { annotations: [...] } + if (Array.isArray(obj.annotations)) { + if (obj.annotations.length === 0) { + return { error: "annotations array must not be empty" }; + } + const items: Record[] = []; + for (let i = 0; i < obj.annotations.length; i++) { + const item = obj.annotations[i]; + if (!item || typeof item !== "object") { + return { error: `annotations[${i}] must be an object` }; + } + items.push(item as Record); + } + return items; + } + + // Single format: { source: "...", ... } + if (typeof obj.source === "string") { + return [obj as Record]; + } + + return { error: 'Missing required "source" field or "annotations" array' }; +} + +function requireString(obj: Record, field: string, index: number): string | ParseError { + const val = obj[field]; + if (typeof val !== "string" || val.length === 0) { + return { error: `annotations[${index}] missing required "${field}" field` }; + } + return val; +} + +// --------------------------------------------------------------------------- +// Plan mode transformer — produces Annotation objects +// --------------------------------------------------------------------------- + +/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ +interface PlanAnnotation { + id: string; + blockId: string; + startOffset: number; + endOffset: number; + type: string; // AnnotationType value + text?: string; + originalText: string; + createdA: number; + author?: string; + source?: string; +} + +const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; + +export function transformPlanInput( + body: unknown, +): { annotations: PlanAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: PlanAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // Must have text content + if (typeof obj.text !== "string" || obj.text.length === 0) { + return { error: `annotations[${i}] missing required "text" field` }; + } + + // Validate type if provided, default to GLOBAL_COMMENT + const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; + if (!VALID_PLAN_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, + }; + } + + // DELETION requires originalText (the text to remove) + if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; + } + + // COMMENT requires originalText so the renderer can pin it to a phrase. + // External agents that want sidebar-only feedback should use GLOBAL_COMMENT + // instead — without a phrase to anchor to, a COMMENT renders as an empty + // quote bubble in the sidebar and exports as `Feedback on: ""`. + if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { + error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + blockId: "external", + startOffset: 0, + endOffset: 0, + type, + text: String(obj.text), + originalText: typeof obj.originalText === "string" ? obj.originalText : "", + createdA: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Review mode transformer — produces CodeAnnotation objects +// --------------------------------------------------------------------------- + +/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ +interface ReviewAnnotation { + id: string; + type: string; // CodeAnnotationType value + scope?: string; + filePath: string; + lineStart: number; + lineEnd: number; + side: string; + text?: string; + suggestedCode?: string; + originalCode?: string; + createdAt: number; + author?: string; + source?: string; + // Agent review metadata (optional — only set by agent review findings) + severity?: string; // "important" | "nit" | "pre_existing" + reasoning?: string; // Validation chain explaining how the issue was confirmed + reviewProfileLabel?: string; // Custom review profile that produced this finding +} + +const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; +const VALID_SIDES = ["old", "new"]; +const VALID_SCOPES = ["line", "file", "general"]; + +/** A review finding's placement, derived from what it carries. */ +export type FindingPlacement = { + scope: "line" | "file" | "general"; + filePath: string; + lineStart: number; + lineEnd: number; +}; + +/** + * Classify an agent review finding by what it carries, so nothing is dropped: + * file + a usable line → a line comment + * file, no line → a whole-file comment + * neither → a general (review-level) comment + * + * For file and general placements the line is 0; for general the path is "". + * Consumers branch on `scope`, never on the sentinel values. + */ +export function classifyFindingPlacement( + filePath: string, + lineStart: number | null | undefined, + lineEnd: number | null | undefined, +): FindingPlacement { + const hasFile = filePath.length > 0; + const hasLine = typeof lineStart === "number"; + if (hasFile && hasLine) { + return { + scope: "line", + filePath, + lineStart, + lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, + }; + } + if (hasFile) { + return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; + } + return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; +} + +export function transformReviewInput( + body: unknown, +): { annotations: ReviewAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: ReviewAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // scope: optional, defaults to "line" + const scope = typeof obj.scope === "string" ? obj.scope : "line"; + if (!VALID_SCOPES.includes(scope)) { + return { + error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, + }; + } + + // Location requirements depend on scope: + // line → filePath + lineStart + lineEnd required. A finding that claims + // a line must carry one, so a broken line finding is rejected + // rather than quietly passing as a vaguer comment. + // file → filePath required; line ignored (defaults to 0). + // general → no file, no line (review-level; defaults to "" / 0). + let filePath = ""; + let lineStart = 0; + let lineEnd = 0; + if (scope !== "general") { + const fp = requireString(obj, "filePath", i); + if (typeof fp !== "string") return fp; + filePath = fp; + if (scope === "line") { + if (typeof obj.lineStart !== "number") { + return { error: `annotations[${i}] missing required "lineStart" field` }; + } + if (typeof obj.lineEnd !== "number") { + return { error: `annotations[${i}] missing required "lineEnd" field` }; + } + lineStart = obj.lineStart; + lineEnd = obj.lineEnd; + } else { + lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; + lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; + } + } + + // side: optional, defaults to "new" + const side = typeof obj.side === "string" ? obj.side : "new"; + if (!VALID_SIDES.includes(side)) { + return { + error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, + }; + } + + // type: optional, defaults to "comment" + const type = typeof obj.type === "string" ? obj.type : "comment"; + if (!VALID_REVIEW_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, + }; + } + + // Must have at least text or suggestedCode + if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { + return { + error: `annotations[${i}] must have at least one of: text, suggestedCode`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + type, + scope, + filePath, + lineStart, + lineEnd, + side, + text: typeof obj.text === "string" ? obj.text : undefined, + suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, + originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, + createdAt: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + // Agent review metadata (optional — only set by agent review findings) + ...(typeof obj.severity === "string" && { severity: obj.severity }), + ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), + ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Annotation Store (generic) +// --------------------------------------------------------------------------- + +type MutationListener = (event: ExternalAnnotationEvent) => void; + +export interface AnnotationStore { + /** Add fully-formed annotations. Returns the added annotations. */ + add(items: T[]): T[]; + /** Remove an annotation by ID. Returns true if found. */ + remove(id: string): boolean; + /** Remove all annotations from a specific source. Returns count removed. */ + clearBySource(source: string): number; + /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ + update(id: string, fields: Partial): T | null; + /** Remove all annotations. Returns count removed. */ + clearAll(): number; + /** Get all annotations (snapshot). */ + getAll(): T[]; + /** Monotonic version counter — incremented on every mutation. */ + readonly version: number; + /** Register a listener for mutation events. Returns unsubscribe function. */ + onMutation(listener: MutationListener): () => void; +} + +/** + * Create an in-memory annotation store. + * + * The store is runtime-agnostic — it holds data and emits events. + * HTTP transport (SSE broadcasting, request parsing) is handled by + * the server-specific adapter (Bun or Pi). + */ +export function createAnnotationStore(): AnnotationStore { + const annotations: T[] = []; + const listeners = new Set>(); + let version = 0; + + function emit(event: ExternalAnnotationEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // Don't let a failing listener break the store + } + } + } + + return { + add(items) { + if (items.length > 0) { + for (const item of items) { + annotations.push(item); + } + version++; + emit({ type: "add", annotations: items }); + } + return items; + }, + + remove(id) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return false; + annotations.splice(idx, 1); + version++; + emit({ type: "remove", ids: [id] }); + return true; + }, + + update(id, fields) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return null; + const merged = { ...annotations[idx], ...fields, id } as T; + annotations[idx] = merged; + version++; + emit({ type: "update", id, annotation: merged }); + return merged; + }, + + clearBySource(source) { + const before = annotations.length; + for (let i = annotations.length - 1; i >= 0; i--) { + if (annotations[i].source === source) { + annotations.splice(i, 1); + } + } + const removed = before - annotations.length; + if (removed > 0) { + version++; + emit({ type: "clear", source }); + } + return removed; + }, + + clearAll() { + const count = annotations.length; + if (count > 0) { + annotations.length = 0; + version++; + emit({ type: "clear" }); + } + return count; + }, + + getAll() { + return [...annotations]; + }, + + get version() { + return version; + }, + + onMutation(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/shared/extract-code-paths.test.ts b/packages/core/extract-code-paths.test.ts similarity index 100% rename from packages/shared/extract-code-paths.test.ts rename to packages/core/extract-code-paths.test.ts diff --git a/packages/core/extract-code-paths.ts b/packages/core/extract-code-paths.ts new file mode 100644 index 000000000..c4ef380fd --- /dev/null +++ b/packages/core/extract-code-paths.ts @@ -0,0 +1,66 @@ +import { + CODE_PATH_BARE_REGEX, + isCodeFilePath, + isCodeFilePathStrict, +} from "./code-file"; + +const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; +const HTML_COMMENT = //g; +// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised +// here mirror the ranges the renderer would consume. +const URL_REGEX = /https?:\/\/[^\s<>"']+/g; +const BACKTICK_SPAN = /`([^`\n]+)`/g; + +/** + * Extract candidate code-file paths from markdown text. Mirrors the renderer's + * detection precedence so the validator only sees paths the renderer would + * actually linkify: + * 1. fenced code blocks and HTML comments are stripped first; + * 2. URL ranges are excised before the bare-prose scan (URLs win); + * 3. backtick spans matching `isCodeFilePath` are collected; + * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and + * `isCodeFilePathStrict` are collected. + * + * Hash anchors (`#L42`) are stripped from results to match the renderer's + * `cleanPath` transform. Returns deduped candidate strings. + */ +export function extractCandidateCodePaths(markdown: string): string[] { + const stripped = markdown + .replace(FENCED_CODE_BLOCK, "") + .replace(HTML_COMMENT, ""); + + const candidates = new Set(); + + let m: RegExpExecArray | null; + const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); + while ((m = backtickRe.exec(stripped)) !== null) { + const inner = m[1].trim(); + if (isCodeFilePath(inner)) { + candidates.add(inner.replace(/#.*$/, "")); + } + } + + for (const line of stripped.split("\n")) { + const urlRanges: Array<[number, number]> = []; + const urlRe = new RegExp(URL_REGEX.source, "g"); + while ((m = urlRe.exec(line)) !== null) { + urlRanges.push([m.index, m.index + m[0].length]); + } + + const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); + while ((m = pathRe.exec(line)) !== null) { + const start = m.index; + const end = start + m[0].length; + const prev = start === 0 ? "" : line[start - 1]; + if (/\w/.test(prev)) continue; + const overlapsUrl = urlRanges.some( + ([s, e]) => start < e && end > s, + ); + if (overlapsUrl) continue; + if (!isCodeFilePathStrict(m[0])) continue; + candidates.add(m[0].replace(/#.*$/, "")); + } + } + + return Array.from(candidates); +} diff --git a/packages/core/favicon.ts b/packages/core/favicon.ts new file mode 100644 index 000000000..c857b8419 --- /dev/null +++ b/packages/core/favicon.ts @@ -0,0 +1,5 @@ +export const FAVICON_SVG = ` + + + P +`; diff --git a/packages/shared/feedback-templates.test.ts b/packages/core/feedback-templates.test.ts similarity index 100% rename from packages/shared/feedback-templates.test.ts rename to packages/core/feedback-templates.test.ts diff --git a/packages/core/feedback-templates.ts b/packages/core/feedback-templates.ts new file mode 100644 index 000000000..02d9b3217 --- /dev/null +++ b/packages/core/feedback-templates.ts @@ -0,0 +1,45 @@ +/** + * Shared feedback templates for all agent integrations. + * + * The plan deny template was tuned in #224 / commit 3dca977 to use strong + * directive framing — Claude was ignoring softer phrasing. + * + * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is + * bundled into the browser SPA. It must NOT import from ./prompts or ./config + * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. + * + * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. + * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. + */ + +export interface PlanDenyFeedbackOptions { + planFilePath?: string; +} + +export interface AnnotateFileFeedbackOptions { + filePath: string; + fileHeader?: "File" | "Folder" | string; +} + +export const planDenyFeedback = ( + feedback: string, + toolName: string = "ExitPlanMode", + options?: PlanDenyFeedbackOptions, +): string => { + const planFileRule = options?.planFilePath + ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` + : ""; + + return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; +}; + +export const annotateFileFeedback = ( + feedback: string, + options: AnnotateFileFeedbackOptions, +): string => { + const fileHeader = options.fileHeader ?? "File"; + return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; +}; + +export const annotateMessageFeedback = (feedback: string): string => + `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; diff --git a/packages/shared/goal-setup.test.ts b/packages/core/goal-setup.test.ts similarity index 100% rename from packages/shared/goal-setup.test.ts rename to packages/core/goal-setup.test.ts diff --git a/packages/core/goal-setup.ts b/packages/core/goal-setup.ts new file mode 100644 index 000000000..0b07f9c40 --- /dev/null +++ b/packages/core/goal-setup.ts @@ -0,0 +1,336 @@ +export type GoalSetupStage = "interview" | "facts"; + +export type GoalSetupAnswerMode = + | "text" + | "single" + | "multi" + | "single-custom" + | "multi-custom" + | "custom"; + +export interface GoalSetupQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface GoalSetupQuestion { + id: string; + prompt: string; + description?: string; + answerMode?: GoalSetupAnswerMode; + recommendedAnswer?: string; + recommendedOptionIds?: string[]; + options?: GoalSetupQuestionOption[]; + required?: boolean; +} + +export interface GoalSetupQuestionAnswer { + questionId: string; + selectedOptionIds: string[]; + customAnswer: string; + note?: string; + answer: string; + completed: boolean; + skipped?: boolean; +} + +export interface GoalSetupInterviewBundle { + stage: "interview"; + title?: string; + goalSlug?: string; + questions: GoalSetupQuestion[]; +} + +export interface GoalSetupFact { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + recommendedAutomatedVerification?: boolean; + automatedVerification: boolean; + previousText?: string; +} + +export interface GoalSetupFactsBundle { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFact[]; + showAccepted?: boolean; +} + +export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; + +export interface GoalSetupInterviewResult { + stage: "interview"; + title?: string; + goalSlug?: string; + answers: GoalSetupQuestionAnswer[]; +} + +export interface GoalSetupFactResult { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + automatedVerification: boolean; + recommendedAutomatedVerification?: boolean; +} + +export interface GoalSetupFactsResult { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFactResult[]; + factsMarkdown: string; +} + +export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; + +function asRecord(value: unknown, context: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeId(value: unknown, fallback: string): string { + const raw = asString(value, fallback).trim(); + return raw || fallback; +} + +function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { + switch (value) { + case "single": + case "multi": + case "single-custom": + case "multi-custom": + case "custom": + case "text": + return value; + default: + return "text"; + } +} + +function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { + const item = asRecord(value, `questions[].options[${index}]`); + const label = asString(item.label).trim(); + if (!label) { + throw new Error(`questions[].options[${index}].label is required`); + } + return { + id: normalizeId(item.id, `option-${index + 1}`), + label, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + }; +} + +function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { + const item = asRecord(value, `questions[${index}]`); + const prompt = asString(item.prompt).trim(); + if (!prompt) { + throw new Error(`questions[${index}].prompt is required`); + } + const options = Array.isArray(item.options) + ? item.options.map(normalizeOption) + : undefined; + + const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) + ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined; + + return { + id: normalizeId(item.id, `question-${index + 1}`), + prompt, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + answerMode: normalizeAnswerMode(item.answerMode), + ...(asString(item.recommendedAnswer).trim() + ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } + : {}), + ...(recommendedOptionIds && recommendedOptionIds.length > 0 + ? { recommendedOptionIds } + : {}), + ...(options && options.length > 0 ? { options } : {}), + required: asBoolean(item.required, true), + }; +} + +function normalizeFact(value: unknown, index: number): GoalSetupFact { + const item = asRecord(value, `facts[${index}]`); + const text = asString(item.text).trim(); + if (!text) { + throw new Error(`facts[${index}].text is required`); + } + const recommended = asBoolean(item.recommendedAutomatedVerification, false); + return { + id: normalizeId(item.id, `fact-${index + 1}`), + text, + accepted: asBoolean(item.accepted, false), + removed: asBoolean(item.removed, false), + ...(asString(item.comment).trim() + ? { comment: asString(item.comment).trim() } + : {}), + recommendedAutomatedVerification: recommended, + automatedVerification: asBoolean(item.automatedVerification, recommended), + ...(asString(item.previousText).trim() + ? { previousText: asString(item.previousText).trim() } + : {}), + }; +} + +export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { + const raw = asRecord(value, "goal setup interview bundle"); + if (!Array.isArray(raw.questions) || raw.questions.length === 0) { + throw new Error("interview bundle requires at least one question"); + } + return { + stage: "interview", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + questions: raw.questions.map(normalizeQuestion), + }; +} + +export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { + const raw = asRecord(value, "goal setup facts bundle"); + if (!Array.isArray(raw.facts)) { + throw new Error("facts bundle requires a facts array"); + } + return { + stage: "facts", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + facts: raw.facts.map(normalizeFact), + showAccepted: asBoolean(raw.showAccepted, false), + }; +} + +export function normalizeGoalSetupBundle( + value: unknown, + expectedStage?: GoalSetupStage +): GoalSetupBundle { + const raw = asRecord(value, "goal setup bundle"); + const stage = expectedStage ?? raw.stage; + if (stage === "interview") return normalizeInterviewBundle(raw); + if (stage === "facts") return normalizeFactsBundle(raw); + throw new Error("goal setup bundle stage must be interview or facts"); +} + +export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { + return ( + answer.selectedOptionIds.length > 0 || + answer.customAnswer.trim().length > 0 || + answer.answer.trim().length > 0 + ); +} + +export function createInterviewResult( + bundle: GoalSetupInterviewBundle, + answers: GoalSetupQuestionAnswer[] +): GoalSetupInterviewResult { + const byId = new Map(answers.map((answer) => [answer.questionId, answer])); + return { + stage: "interview", + title: bundle.title, + goalSlug: bundle.goalSlug, + answers: bundle.questions.map((question) => { + const answer = byId.get(question.id); + const normalized: GoalSetupQuestionAnswer = { + questionId: question.id, + selectedOptionIds: Array.isArray(answer?.selectedOptionIds) + ? answer!.selectedOptionIds + : [], + customAnswer: asString(answer?.customAnswer), + ...(asString(answer?.note).trim() + ? { note: asString(answer?.note).trim() } + : {}), + answer: asString(answer?.answer), + completed: asBoolean(answer?.completed, false), + }; + const completed = normalized.completed || hasQuestionAnswer(normalized); + const skipped = asBoolean(answer?.skipped, false) && !completed; + return { + ...normalized, + completed, + ...(skipped ? { skipped: true } : {}), + }; + }), + }; +} + +export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { + if (bundle.showAccepted) return bundle.facts; + return bundle.facts.filter((fact) => !fact.accepted); +} + +export function createFactsResult( + bundle: GoalSetupFactsBundle, + facts: GoalSetupFactResult[] +): GoalSetupFactsResult { + const byId = new Map(facts.map((fact) => [fact.id, fact])); + const merged = bundle.facts.map((fact) => { + const next = byId.get(fact.id); + const removed = asBoolean(next?.removed, fact.removed); + const text = asString(next?.text, fact.text).trim(); + if (!removed && !text) { + throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); + } + const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") + ? asString(next.comment) + : asString(fact.comment) + ).trim(); + return { + id: fact.id, + text: text || fact.text, + accepted: asBoolean(next?.accepted, fact.accepted), + removed, + ...(comment ? { comment } : {}), + automatedVerification: asBoolean( + next?.automatedVerification, + fact.automatedVerification + ), + recommendedAutomatedVerification: + next?.recommendedAutomatedVerification ?? + fact.recommendedAutomatedVerification, + }; + }); + + return { + stage: "facts", + title: bundle.title, + goalSlug: bundle.goalSlug, + facts: merged, + factsMarkdown: factsResultToMarkdown(merged), + }; +} + +export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { + const accepted = facts.filter((fact) => fact.accepted && !fact.removed); + if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; + + const lines = ["# Facts", ""]; + for (const fact of accepted) { + lines.push(`- ${fact.text}`); + } + return lines.join("\n"); +} diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 000000000..0f30b3baf --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,2 @@ +export * from './ai-context'; +export type { EditorAnnotation } from './types'; diff --git a/packages/core/open-in-apps.ts b/packages/core/open-in-apps.ts new file mode 100644 index 000000000..122af8feb --- /dev/null +++ b/packages/core/open-in-apps.ts @@ -0,0 +1,189 @@ +/** + * Open-in-App Catalog — single source of truth. + * + * Shared between the Bun/Pi servers (which launch the app) and the UI (which + * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure + * data + types only. + * + * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the + * app per platform: + * - mac.appName -> `open -a "" ` + * - win.bin -> ` ` (resolved against PATH) + * - linux.bin -> ` ` + * + * `kind` drives launch semantics: + * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, + * linux: open the parent dir) + * - editor -> open the file itself + * - terminal -> open the file's parent directory + * + * One special id has no platform launch fields: + * - 'reveal' (kind file-manager) — uses the OS file manager + */ + +export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; + +export interface OpenInApp { + /** Stable identifier persisted in the cookie + sent to the server. */ + id: string; + /** Human-readable label. For 'reveal' this is resolved per-platform. */ + label: string; + kind: OpenInKind; + /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ + icon: string; + /** macOS application bundle/display name passed to `open -a`. */ + mac?: { appName: string }; + /** Windows PATH binary. */ + win?: { bin: string }; + /** Linux PATH binary. */ + linux?: { bin: string }; +} + +/** + * The catalog, in menu order. The UI groups by `kind` + * (file-manager + default first, then editors, then terminals). + */ +export const OPEN_IN_APPS: OpenInApp[] = [ + // ── File manager (always available) ──────────────────────────────────── + { + id: 'reveal', + label: 'Finder', // resolved per-platform, see resolveRevealLabel + kind: 'file-manager', + icon: 'finder', // resolved per-platform, see resolveRevealIcon + }, + + // ── Editors ──────────────────────────────────────────────────────────── + { + id: 'vscode', + label: 'VS Code', + kind: 'editor', + icon: 'vscode', + mac: { appName: 'Visual Studio Code' }, + win: { bin: 'code' }, + linux: { bin: 'code' }, + }, + { + id: 'cursor', + label: 'Cursor', + kind: 'editor', + icon: 'cursor', + mac: { appName: 'Cursor' }, + win: { bin: 'cursor' }, + linux: { bin: 'cursor' }, + }, + { + id: 'zed', + label: 'Zed', + kind: 'editor', + icon: 'zed', + mac: { appName: 'Zed' }, + win: { bin: 'zed' }, + linux: { bin: 'zed' }, + }, + { + id: 'sublime-text', + label: 'Sublime Text', + kind: 'editor', + icon: 'sublime-text', + mac: { appName: 'Sublime Text' }, + win: { bin: 'subl' }, + linux: { bin: 'subl' }, + }, + { + id: 'textmate', + label: 'TextMate', + kind: 'editor', + icon: 'textmate', + mac: { appName: 'TextMate' }, + }, + { + id: 'antigravity', + label: 'Antigravity', + kind: 'editor', + icon: 'antigravity', + mac: { appName: 'Antigravity' }, + }, + { + id: 'xcode', + label: 'Xcode', + kind: 'editor', + icon: 'xcode', + mac: { appName: 'Xcode' }, + }, + { + id: 'android-studio', + label: 'Android Studio', + kind: 'editor', + icon: 'android-studio', + mac: { appName: 'Android Studio' }, + }, + + // ── Terminals ────────────────────────────────────────────────────────── + { + id: 'terminal', + label: 'Terminal', + kind: 'terminal', + icon: 'terminal', + mac: { appName: 'Terminal' }, + }, + { + id: 'iterm2', + label: 'iTerm2', + kind: 'terminal', + icon: 'iterm2', + mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" + }, + { + id: 'ghostty', + label: 'Ghostty', + kind: 'terminal', + icon: 'ghostty', + mac: { appName: 'Ghostty' }, + }, + { + id: 'warp', + label: 'Warp', + kind: 'terminal', + icon: 'warp', + mac: { appName: 'Warp' }, + }, + { + id: 'powershell', + label: 'PowerShell', + kind: 'terminal', + icon: 'powershell', + win: { bin: 'powershell' }, + }, +]; + +export type OpenInPlatform = 'mac' | 'win' | 'linux'; + +/** + * Per-platform label for the 'reveal' (file-manager) entry. + */ +export function resolveRevealLabel(platform: OpenInPlatform): string { + switch (platform) { + case 'win': + return 'Explorer'; + case 'linux': + return 'Files'; + case 'mac': + default: + return 'Finder'; + } +} + +/** + * Per-platform icon for the 'reveal' (file-manager) entry: + * finder on mac/linux, file-explorer on win. + */ +export function resolveRevealIcon(platform: OpenInPlatform): string { + return platform === 'win' ? 'file-explorer' : 'finder'; +} + +/** + * Look up a catalog entry by id. + */ +export function getOpenInApp(id: string): OpenInApp | undefined { + return OPEN_IN_APPS.find((app) => app.id === id); +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..e729364a8 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@plannotator/core", + "version": "0.21.1", + "type": "module", + "exports": { + "./agents": "./agents.ts", + "./agent-jobs": "./agent-jobs.ts", + "./agent-terminal": "./agent-terminal.ts", + "./browser-paths": "./browser-paths.ts", + "./code-file": "./code-file.ts", + "./compress": "./compress.ts", + "./crypto": "./crypto.ts", + "./external-annotation": "./external-annotation.ts", + "./extract-code-paths": "./extract-code-paths.ts", + "./favicon": "./favicon.ts", + "./feedback-templates": "./feedback-templates.ts", + "./goal-setup": "./goal-setup.ts", + "./open-in-apps": "./open-in-apps.ts", + "./project": "./project.ts", + "./source-save": "./source-save.ts", + "./config-types": "./config-types.ts", + "./storage-types": "./storage-types.ts", + "./workspace-status-types": "./workspace-status-types.ts", + "./ai-context": "./ai-context.ts", + "./types": "./types.ts", + ".": "./index.ts" + }, + "files": ["**/*.ts", "!**/*.test.ts"], + "dependencies": {}, + "devDependencies": { + "typescript": "~5.8.2" + } +} diff --git a/packages/core/project.ts b/packages/core/project.ts new file mode 100644 index 000000000..23c130e81 --- /dev/null +++ b/packages/core/project.ts @@ -0,0 +1,71 @@ +/** + * Project Utility — Pure Functions + * + * String sanitization and path extraction helpers. + * Runtime-agnostic: no Bun or Node-specific APIs. + */ + +/** + * Sanitize a string for use as a tag + * - lowercase + * - replace spaces/underscores with hyphens + * - remove special characters + * - trim to reasonable length + */ +export function sanitizeTag(name: string): string | null { + if (!name || typeof name !== "string") return null; + + const sanitized = name + .toLowerCase() + .trim() + .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens + .replace(/[^a-z0-9-]/g, "") // remove special chars + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, "") // trim leading/trailing hyphens + .slice(0, 30); // max 30 chars + + return sanitized.length >= 2 ? sanitized : null; +} + +/** + * Extract repo name from a git root path + */ +export function extractRepoName(gitRootPath: string): string | null { + if (!gitRootPath || typeof gitRootPath !== "string") return null; + + const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + return sanitizeTag(name); +} + +/** + * Extract directory name from a path + */ +export function extractDirName(path: string): string | null { + if (!path || typeof path !== "string") return null; + + const trimmed = path.trim().replace(/\/+$/, ""); + if (trimmed === "" || trimmed === "/") return null; + + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + // Skip generic names + const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); + if (skipNames.has(name.toLowerCase())) return null; + + return sanitizeTag(name); +} + +/** + * Extract hostname from a URL string, or return the original string on failure. + */ +export function hostnameOrFallback(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} diff --git a/packages/shared/source-save.test.ts b/packages/core/source-save.test.ts similarity index 100% rename from packages/shared/source-save.test.ts rename to packages/core/source-save.test.ts diff --git a/packages/core/source-save.ts b/packages/core/source-save.ts new file mode 100644 index 000000000..434f72183 --- /dev/null +++ b/packages/core/source-save.ts @@ -0,0 +1,138 @@ +export type SourceSaveLanguage = "markdown" | "mdx" | "text"; + +export type SourceSaveDisabledReason = + | "not-annotate-mode" + | "not-local-file" + | "unsupported-extension" + | "converted-source" + | "html-render" + | "folder-mode" + | "message-mode" + | "shared-session" + | "missing-file" + | "unreadable-file"; + +export type SourceSaveScope = "single-file" | "folder-file"; + +export type SourceFileEol = "lf" | "crlf" | "mixed" | "none"; + +export interface SourceFileSnapshot { + text: string; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; +} + +export type SourceSaveCapability = + | { + enabled: true; + kind: "local-text-file"; + scope: SourceSaveScope; + path: string; + basename: string; + language: SourceSaveLanguage; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; + } + | { + enabled: false; + reason: SourceSaveDisabledReason; + }; + +export interface SourceSaveRequest { + path?: string; + text: string; + baseHash: string; + baseMtimeMs?: number; + baseEol?: SourceFileEol; + allowMissingBase?: boolean; +} + +export type SourceSaveResponse = + | { + ok: true; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; + } + | { + ok: false; + code: "conflict"; + message: string; + currentText: string; + currentHash: string; + currentMtimeMs: number; + currentSize: number; + currentEol: SourceFileEol; + } + | { + ok: false; + code: "not-writable" | "write-failed" | "invalid-request"; + message: string; + }; + +export type SourceSaveConflictResponse = Extract; + +export function isSourceFileEol(value: unknown): value is SourceFileEol { + return value === "lf" || value === "crlf" || value === "mixed" || value === "none"; +} + +export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): response is SourceSaveConflictResponse { + if (!("code" in response) || response.code !== "conflict") return false; + const conflict = response as SourceSaveConflictResponse; + return ( + typeof conflict.currentText === "string" && + typeof conflict.currentHash === "string" && + typeof conflict.currentMtimeMs === "number" && + typeof conflict.currentSize === "number" && + isSourceFileEol(conflict.currentEol) + ); +} + +export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; + +export function isSourceSaveFilePath(filePath: string): boolean { + return SOURCE_SAVE_FILE_REGEX.test(filePath); +} + +export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | null { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".mdx")) return "mdx"; + if (lower.endsWith(".md")) return "markdown"; + if (lower.endsWith(".txt")) return "text"; + return null; +} + +export function basenameFromPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + return normalized.split("/").pop() || filePath; +} + +export function disabledSourceSave(reason: SourceSaveDisabledReason): SourceSaveCapability { + return { enabled: false, reason }; +} + +export function enabledSourceSave( + scope: SourceSaveScope, + filePath: string, + snapshot: SourceFileSnapshot, +): SourceSaveCapability { + const language = getSourceSaveLanguage(filePath); + if (!language) return disabledSourceSave("unsupported-extension"); + return { + enabled: true, + kind: "local-text-file", + scope, + path: filePath, + basename: basenameFromPath(filePath), + language, + hash: snapshot.hash, + mtimeMs: snapshot.mtimeMs, + size: snapshot.size, + eol: snapshot.eol, + }; +} diff --git a/packages/core/storage-types.ts b/packages/core/storage-types.ts new file mode 100644 index 000000000..2e73c5811 --- /dev/null +++ b/packages/core/storage-types.ts @@ -0,0 +1,8 @@ +export interface ArchivedPlan { + filename: string; + title: string; + date: string; + timestamp: string; // ISO string from file mtime + status: "approved" | "denied" | "unknown"; + size: number; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..008df21c7 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/core/types.ts b/packages/core/types.ts new file mode 100644 index 000000000..c54515792 --- /dev/null +++ b/packages/core/types.ts @@ -0,0 +1,10 @@ +// Editor annotations from VS Code extension (ephemeral, in-memory only) +export interface EditorAnnotation { + id: string; + filePath: string; // workspace-relative (e.g., "src/auth.ts") + selectedText: string; + lineStart: number; // 1-based + lineEnd: number; // 1-based + comment?: string; + createdAt: number; +} diff --git a/packages/core/workspace-status-types.ts b/packages/core/workspace-status-types.ts new file mode 100644 index 000000000..0ec63af29 --- /dev/null +++ b/packages/core/workspace-status-types.ts @@ -0,0 +1,39 @@ +export type WorkspaceFileStatus = + | "modified" + | "added" + | "deleted" + | "renamed" + | "copied" + | "typechange" + | "conflicted" + | "untracked"; + +export interface WorkspaceFileChange { + path: string; + repoRelativePath: string; + oldPath?: string; + status: WorkspaceFileStatus; + additions: number; + deletions: number; + staged: boolean; + unstaged: boolean; +} + +export interface WorkspaceStatusPayload { + available: boolean; + rootPath: string; + repoRoot?: string; + files: Record; + totals: { + files: number; + additions: number; + deletions: number; + }; + error?: string; +} + +export interface GitRepositoryInfo { + repoRoot: string; + gitDir: string; + gitCommonDir: string; +} diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 119715010..84571fb30 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -53,7 +53,7 @@ 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 { ScrollViewportProvider } from '@plannotator/ui/hooks/useScrollViewport'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; import { @@ -106,7 +106,7 @@ import type { AgentTerminalCapability } from '@plannotator/shared/agent-terminal // 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 { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -3892,7 +3892,7 @@ const App: React.FC = () => { )} {/* Main Content */} - +
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */} {taterMode && } @@ -4431,7 +4431,7 @@ const App: React.FC = () => { )}
-
+ {/* Code File Popout */} {codeFilePopout.popoutProps && ( diff --git a/packages/editor/index.css b/packages/editor/index.css index b2e900b23..95d7b544d 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -115,120 +115,6 @@ pre code.hljs .hljs-code { color: oklch(0.45 0.20 280) !important; } -/* Annotation highlights */ -.annotation-highlight { - border-radius: 2px; - padding: 0 2px; - margin: 0 -2px; -} - -.annotation-highlight.deletion { - background: oklch(from var(--destructive) l c h / 0.35); - text-decoration: line-through; - text-decoration-color: var(--destructive); - text-decoration-thickness: 2px; -} - -.annotation-highlight.comment { - background: oklch(0.70 0.18 60 / 0.3); - border-bottom: 2px solid var(--accent); -} - -/* Light mode: softer highlights */ -.light .annotation-highlight.deletion { - background: oklch(0.65 0.22 25 / 0.2); -} - -.light .annotation-highlight.comment { - background: oklch(0.70 0.20 60 / 0.15); -} - -.annotation-highlight.focused { - background: oklch(from var(--focus-highlight) l c h / 0.45) !important; - box-shadow: 0 0 8px oklch(from var(--focus-highlight) l c h / 0.4); - border-bottom: 2px solid var(--focus-highlight); - filter: none; -} - -.light .annotation-highlight.focused { - background: oklch(0.70 0.22 200 / 0.3) !important; - box-shadow: 0 0 6px oklch(0.60 0.20 200 / 0.3); -} - -.annotation-highlight:hover { - filter: brightness(1.2); - cursor: pointer; -} - -/* ======================================== - Plan Diff Styles - ======================================== */ - -/* Clean diff view - added content */ -.plan-diff-added { - border-left: 3px solid var(--success); - background: oklch(from var(--success) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-added { - background: oklch(from var(--success) l c h / 0.06); -} - -/* Clean diff view - removed content */ -.plan-diff-removed { - border-left: 3px solid var(--destructive); - background: oklch(from var(--destructive) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-removed { - background: oklch(from var(--destructive) l c h / 0.06); -} - -/* Clean diff view - modified content (mix of additions and deletions in one - block, rendered inline via word-level diff). - Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events - — the whole block matters, so a loud fill is the right signal. Modify is - a WORD-scope event — the words matter, and the inline red-struck / - green-highlighted word markers already grab attention. A block-level fill - would compete with that inline work; an amber gutter on a normal - background says "look inside, the change is in the text" while staying - consistent with the green/red/yellow diff convention. */ -.plan-diff-modified { - border-left: 3px solid oklch(from var(--warning) l c h / 0.75); - background: transparent; - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} - -/* Clean diff view - unchanged (dimmed) */ -.plan-diff-unchanged { - /* handled via opacity in component */ -} - -/* Raw diff view - line styles */ -.plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.15); - color: var(--success); -} -.plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.15); - color: var(--destructive); - opacity: 0.75; - text-decoration: line-through; - text-decoration-color: oklch(from var(--destructive) l c h / 0.4); -} -.light .plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.12); -} -.light .plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.12); -} - /* ======================================== Sidebar ======================================== */ diff --git a/packages/shared/agent-jobs.ts b/packages/shared/agent-jobs.ts index a97e980f9..f14df7ec7 100644 --- a/packages/shared/agent-jobs.ts +++ b/packages/shared/agent-jobs.ts @@ -1,149 +1 @@ -/** - * Agent Jobs — shared types, state machine, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and (future) Node handler import - * this module and wrap it with their respective HTTP transport layers. - * - * Mirrors packages/shared/external-annotation.ts in structure. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; - -/** - * Snapshot of the diff the reviewer was looking at when this job was launched. - * Carried on the job so downstream UIs (agent-result panel "Copy All") export - * the same `**Diff:** ...` header the job was actually run against — if the - * reviewer switches the UI to a different diff afterwards, the job's snapshot - * still reflects truth. Structurally compatible with the UI-side - * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. - */ -export interface AgentJobDiffContext { - mode: string; - base?: string; - worktreePath?: string | null; -} - -export interface AgentJobInfo { - /** Unique job identifier (UUID). */ - id: string; - /** Source identifier for external annotations — "agent-{id prefix}". */ - source: string; - /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ - provider: string; - /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ - engine?: string; - /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ - model?: string; - /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ - effort?: string; - /** Codex reasoning effort level (e.g., "high", "medium"). */ - reasoningEffort?: string; - /** Whether Codex fast mode (service_tier=fast) was enabled. */ - fastMode?: boolean; - /** Human-readable label for the job. */ - label: string; - /** Current lifecycle status. */ - status: AgentJobStatus; - /** Timestamp when the job was created. */ - startedAt: number; - /** Timestamp when the job reached a terminal state. */ - endedAt?: number; - /** Process exit code (set on done/failed). */ - exitCode?: number; - /** Last ~500 chars of stderr on failure. */ - error?: string; - /** The actual command that was spawned (for display/debug). */ - command: string[]; - /** Working directory where the process was spawned. */ - cwd?: string; - /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ - prompt?: string; - /** Review summary set by the agent on completion. */ - summary?: { - correctness: string; - explanation: string; - confidence: number; - }; - /** PR URL at launch time — used to attribute findings to the correct PR. */ - prUrl?: string; - /** PR diff scope at launch time — "layer" or "full-stack". */ - diffScope?: string; - /** Diff context at launch time (see AgentJobDiffContext). */ - diffContext?: AgentJobDiffContext; - /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ - reviewProfileId?: string; - /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ - reviewProfileLabel?: string; -} - -export interface AgentCapability { - id: string; - name: string; - available: boolean; -} - -export interface AgentCapabilities { - mode: "plan" | "review" | "annotate"; - providers: AgentCapability[]; - /** True if at least one provider is available. */ - available: boolean; -} - -// --------------------------------------------------------------------------- -// SSE event types -// --------------------------------------------------------------------------- - -export type AgentJobEvent = - | { type: "snapshot"; jobs: AgentJobInfo[] } - | { type: "job:started"; job: AgentJobInfo } - | { type: "job:updated"; job: AgentJobInfo } - | { type: "job:completed"; job: AgentJobInfo } - | { type: "job:log"; jobId: string; delta: string } - | { type: "jobs:cleared" }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeAgentSSEEvent(event: AgentJobEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Check if a status is terminal (no further transitions). */ -export function isTerminalStatus(status: AgentJobStatus): boolean { - return status === "done" || status === "failed" || status === "killed"; -} - -/** Generate the source identifier for a job from its ID. */ -export function jobSource(id: string): string { - return "agent-" + id.slice(0, 8); -} - -// --------------------------------------------------------------------------- -// Review ingestion completion semantics -// --------------------------------------------------------------------------- - -/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ -export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; - -/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ -export function markJobReviewFailed(job: AgentJobInfo, error: string): void { - job.status = "failed"; - job.error = error; -} +export * from '@plannotator/core/agent-jobs'; diff --git a/packages/shared/agent-terminal.ts b/packages/shared/agent-terminal.ts index 42f8339a3..40f8a1646 100644 --- a/packages/shared/agent-terminal.ts +++ b/packages/shared/agent-terminal.ts @@ -1,53 +1 @@ -export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; - -export function buildAgentTerminalWsPath(token: string): string { - if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { - throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); - } - return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; -} - -export function isAgentTerminalWsRoute(pathname: string): boolean { - return pathname === AGENT_TERMINAL_WS_BASE_PATH || - pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); -} - -export type AgentTerminalDisabledReason = - | "not-annotate-mode" - | "remote-disabled" - | "runtime-unavailable" - | "webtui-unavailable" - | "pty-unavailable" - | "unsupported-runtime"; - -export type AgentTerminalAgent = { - id: string; - name: string; - available: boolean; -}; - -export type AgentTerminalCapability = - | { - enabled: true; - cwd: string; - wsPath: string; - agents: AgentTerminalAgent[]; - } - | { - enabled: false; - reason: AgentTerminalDisabledReason; - message?: string; - }; - -export type AnnotateAgentTerminalMode = - | "annotate" - | "annotate-last" - | "annotate-folder" - | string - | undefined; - -export function supportsAnnotateAgentTerminalMode( - mode: AnnotateAgentTerminalMode, -): boolean { - return mode === "annotate" || mode === "annotate-folder"; -} +export * from '@plannotator/core/agent-terminal'; diff --git a/packages/shared/agents.ts b/packages/shared/agents.ts index cc9994135..2945300fb 100644 --- a/packages/shared/agents.ts +++ b/packages/shared/agents.ts @@ -1,53 +1 @@ -/** - * Centralized agent configuration — single source of truth for all supported agents. - * - * To add a new agent: - * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, - * optional AI provider types) - * 2. If detection is via environment variable, add it to the detection chain - * in apps/hook/server/index.ts (detectedOrigin constant) - * 3. That's it — all UI components read from this config automatically - */ - -type AgentConfigEntry = { - name: string; - badge: string; - /** AI provider type(s) that naturally match this origin, in preference order. */ - aiProviderTypes?: readonly string[]; -}; - -export const AGENT_CONFIG = { - 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, - 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, - 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, - 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, - 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, - 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, - 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, - 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, - 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, -} as const satisfies Record; - -/** All recognized origin values. */ -export type Origin = keyof typeof AGENT_CONFIG; - -/** Resolve an origin to a human-readable agent name. */ -export function getAgentName(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; - return 'Coding Agent'; -} - -/** Resolve an origin to Tailwind badge classes. */ -export function getAgentBadge(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; - return 'bg-zinc-500/20 text-zinc-400'; -} - -/** Resolve an origin to matching AI provider types, in preference order. */ -export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { - if (origin && origin in AGENT_CONFIG) { - const config = AGENT_CONFIG[origin as Origin]; - return 'aiProviderTypes' in config ? config.aiProviderTypes : []; - } - return []; -} +export * from '@plannotator/core/agents'; diff --git a/packages/shared/browser-paths.ts b/packages/shared/browser-paths.ts index ce867a505..f2f297ab6 100644 --- a/packages/shared/browser-paths.ts +++ b/packages/shared/browser-paths.ts @@ -1,25 +1 @@ -export function normalizeBrowserPath(path: string): string { - const withForwardSlashes = path.replace(/\\/g, "/"); - const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; - const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); - if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; - return collapsed.replace(/\/+$/, ""); -} - -export function dirnameBrowserPath(path: string): string { - const normalized = normalizeBrowserPath(path); - const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); - if (driveRootMatch) return `${driveRootMatch[1]}/`; - const index = normalized.lastIndexOf("/"); - if (index < 0) return normalized; - if (index === 0) return "/"; - return normalized.slice(0, index); -} - -export function pathIsInsideDir(path: string, dir: string): boolean { - const normalizedPath = normalizeBrowserPath(path); - const normalizedDir = normalizeBrowserPath(dir); - if (!normalizedDir) return normalizedPath === ""; - const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; - return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); -} +export * from '@plannotator/core/browser-paths'; diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index 43633a766..dc82b5006 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -1,41 +1 @@ -export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; - -export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; - -const IMPLAUSIBLE_CHARS = /[{},*?\s]/; - -export function isPlausibleCodeFilePath(input: string): boolean { - return !IMPLAUSIBLE_CHARS.test(input); -} - -export interface ParsedCodePath { - filePath: string; - line?: number; - lineEnd?: number; -} - -const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; - -export function parseCodePath(input: string): ParsedCodePath { - const clean = input.replace(/#.*$/, ''); - const m = clean.match(LINE_SUFFIX_RE); - if (!m) return { filePath: clean }; - let line = Number.parseInt(m[1], 10); - let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; - if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } - return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; -} - -export function stripLineRef(input: string): string { - return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); -} - -export function isCodeFilePath(input: string): boolean { - if (!isPlausibleCodeFilePath(input)) return false; - return CODE_FILE_REGEX.test(stripLineRef(input)) - && !input.startsWith('http://') && !input.startsWith('https://'); -} - -export function isCodeFilePathStrict(input: string): boolean { - return input.includes('/') && isCodeFilePath(input); -} +export * from '@plannotator/core/code-file'; diff --git a/packages/shared/compress.ts b/packages/shared/compress.ts index 70c5099ac..723038922 100644 --- a/packages/shared/compress.ts +++ b/packages/shared/compress.ts @@ -1,51 +1 @@ -/** - * Portable deflate-raw + base64url compression. - * - * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works - * in browsers, Bun, and edge runtimes. Both @plannotator/server and - * @plannotator/ui import from here — single source of truth. - */ - -export async function compress(data: unknown): Promise { - const json = JSON.stringify(data); - const byteArray = new TextEncoder().encode(json); - - const stream = new CompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const compressed = new Uint8Array(buffer); - - // Loop instead of spread to avoid RangeError on large payloads - // (String.fromCharCode(...arr) has a ~65K argument limit) - let binary = ''; - for (let i = 0; i < compressed.length; i++) { - binary += String.fromCharCode(compressed[i]); - } - const base64 = btoa(binary); - return base64 - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -export async function decompress(b64: string): Promise { - const base64 = b64 - .replace(/-/g, '+') - .replace(/_/g, '/'); - - const binary = atob(base64); - const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); - - const stream = new DecompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const json = new TextDecoder().decode(buffer); - - return JSON.parse(json); -} +export * from '@plannotator/core/compress'; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 7ab823e44..68a3cecc9 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,24 +10,8 @@ import { getPlannotatorDataDir } from "./data-dir"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; -export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; -export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; - -export interface DiffOptions { - diffStyle?: 'split' | 'unified'; - overflow?: 'scroll' | 'wrap'; - diffIndicators?: 'bars' | 'classic' | 'none'; - lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; - showLineNumbers?: boolean; - showDiffBackground?: boolean; - fontFamily?: string; - fontSize?: string; - tabSize?: number; - hideWhitespace?: boolean; - expandUnchanged?: boolean; - defaultDiffType?: DefaultDiffType; - lineBgIntensity?: DiffLineBgIntensity; -} +import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { diff --git a/packages/shared/crypto.ts b/packages/shared/crypto.ts index 0161e6dcd..663340856 100644 --- a/packages/shared/crypto.ts +++ b/packages/shared/crypto.ts @@ -1,97 +1 @@ -/** - * AES-256-GCM encryption for zero-knowledge paste storage. - * - * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. - * The key never leaves the client; it lives in the URL fragment. - */ - -/** - * Encrypt a compressed base64url string with a fresh AES-256-GCM key. - * - * Returns { ciphertext, key } where: - * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) - * - key: base64url-encoded 256-bit key for the URL fragment - */ -export async function encrypt( - compressedData: string -): Promise<{ ciphertext: string; key: string }> { - const cryptoKey = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt'] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - const plaintext = new TextEncoder().encode(compressedData); - - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - plaintext - ); - - // Prepend IV to ciphertext (IV || ciphertext+tag) - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); - - return { - ciphertext: bytesToBase64url(combined), - key: bytesToBase64url(new Uint8Array(rawKey)), - }; -} - -/** - * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. - * - * Expects ciphertext format: base64url(IV || encrypted+tag) - * Returns the original compressed base64url string. - */ -export async function decrypt( - ciphertext: string, - key: string -): Promise { - const combined = base64urlToBytes(ciphertext); - const rawKey = base64urlToBytes(key); - - const iv = combined.slice(0, 12); - const encrypted = combined.slice(12); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - rawKey.buffer as ArrayBuffer, - { name: 'AES-GCM', length: 256 }, - false, - ['decrypt'] - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - encrypted - ); - - return new TextDecoder().decode(decrypted); -} - -// --- Helpers --- - -function bytesToBase64url(bytes: Uint8Array): string { - // Loop to avoid RangeError on large payloads (same approach as compress.ts) - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -function base64urlToBytes(b64: string): Uint8Array { - const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - return Uint8Array.from(binary, c => c.charCodeAt(0)); -} +export * from '@plannotator/core/crypto'; diff --git a/packages/shared/external-annotation.ts b/packages/shared/external-annotation.ts index 2260e3f87..f1d1f2838 100644 --- a/packages/shared/external-annotation.ts +++ b/packages/shared/external-annotation.ts @@ -1,455 +1 @@ -/** - * External Annotations — shared types, store logic, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and Pi server handler import this module - * and wrap it with their respective HTTP transport layers. - * - * The store is generic — plan servers store Annotation objects, - * review servers store CodeAnnotation objects. The mode-specific - * input transformers handle validation and field assignment. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Constraint for any annotation type the store can hold. */ -export type StorableAnnotation = { id: string; source?: string }; - -export type ExternalAnnotationEvent = - | { type: "snapshot"; annotations: T[] } - | { type: "add"; annotations: T[] } - | { type: "remove"; ids: string[] } - | { type: "clear"; source?: string } - | { type: "update"; id: string; annotation: T }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeSSEEvent(event: ExternalAnnotationEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Input validation — shared helpers -// --------------------------------------------------------------------------- - -export interface ParseError { - error: string; -} - -/** - * Unwrap a POST body into an array of raw input objects. - * - * Accepts either: - * - A single annotation object: `{ source: "...", ... }` - * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` - */ -function unwrapBody(body: unknown): Record[] | ParseError { - if (!body || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record; - - // Batch format: { annotations: [...] } - if (Array.isArray(obj.annotations)) { - if (obj.annotations.length === 0) { - return { error: "annotations array must not be empty" }; - } - const items: Record[] = []; - for (let i = 0; i < obj.annotations.length; i++) { - const item = obj.annotations[i]; - if (!item || typeof item !== "object") { - return { error: `annotations[${i}] must be an object` }; - } - items.push(item as Record); - } - return items; - } - - // Single format: { source: "...", ... } - if (typeof obj.source === "string") { - return [obj as Record]; - } - - return { error: 'Missing required "source" field or "annotations" array' }; -} - -function requireString(obj: Record, field: string, index: number): string | ParseError { - const val = obj[field]; - if (typeof val !== "string" || val.length === 0) { - return { error: `annotations[${index}] missing required "${field}" field` }; - } - return val; -} - -// --------------------------------------------------------------------------- -// Plan mode transformer — produces Annotation objects -// --------------------------------------------------------------------------- - -/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ -interface PlanAnnotation { - id: string; - blockId: string; - startOffset: number; - endOffset: number; - type: string; // AnnotationType value - text?: string; - originalText: string; - createdA: number; - author?: string; - source?: string; -} - -const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; - -export function transformPlanInput( - body: unknown, -): { annotations: PlanAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: PlanAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // Must have text content - if (typeof obj.text !== "string" || obj.text.length === 0) { - return { error: `annotations[${i}] missing required "text" field` }; - } - - // Validate type if provided, default to GLOBAL_COMMENT - const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; - if (!VALID_PLAN_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, - }; - } - - // DELETION requires originalText (the text to remove) - if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; - } - - // COMMENT requires originalText so the renderer can pin it to a phrase. - // External agents that want sidebar-only feedback should use GLOBAL_COMMENT - // instead — without a phrase to anchor to, a COMMENT renders as an empty - // quote bubble in the sidebar and exports as `Feedback on: ""`. - if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { - error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - blockId: "external", - startOffset: 0, - endOffset: 0, - type, - text: String(obj.text), - originalText: typeof obj.originalText === "string" ? obj.originalText : "", - createdA: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Review mode transformer — produces CodeAnnotation objects -// --------------------------------------------------------------------------- - -/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ -interface ReviewAnnotation { - id: string; - type: string; // CodeAnnotationType value - scope?: string; - filePath: string; - lineStart: number; - lineEnd: number; - side: string; - text?: string; - suggestedCode?: string; - originalCode?: string; - createdAt: number; - author?: string; - source?: string; - // Agent review metadata (optional — only set by agent review findings) - severity?: string; // "important" | "nit" | "pre_existing" - reasoning?: string; // Validation chain explaining how the issue was confirmed - reviewProfileLabel?: string; // Custom review profile that produced this finding -} - -const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; -const VALID_SIDES = ["old", "new"]; -const VALID_SCOPES = ["line", "file", "general"]; - -/** A review finding's placement, derived from what it carries. */ -export type FindingPlacement = { - scope: "line" | "file" | "general"; - filePath: string; - lineStart: number; - lineEnd: number; -}; - -/** - * Classify an agent review finding by what it carries, so nothing is dropped: - * file + a usable line → a line comment - * file, no line → a whole-file comment - * neither → a general (review-level) comment - * - * For file and general placements the line is 0; for general the path is "". - * Consumers branch on `scope`, never on the sentinel values. - */ -export function classifyFindingPlacement( - filePath: string, - lineStart: number | null | undefined, - lineEnd: number | null | undefined, -): FindingPlacement { - const hasFile = filePath.length > 0; - const hasLine = typeof lineStart === "number"; - if (hasFile && hasLine) { - return { - scope: "line", - filePath, - lineStart, - lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, - }; - } - if (hasFile) { - return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; - } - return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; -} - -export function transformReviewInput( - body: unknown, -): { annotations: ReviewAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: ReviewAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // scope: optional, defaults to "line" - const scope = typeof obj.scope === "string" ? obj.scope : "line"; - if (!VALID_SCOPES.includes(scope)) { - return { - error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, - }; - } - - // Location requirements depend on scope: - // line → filePath + lineStart + lineEnd required. A finding that claims - // a line must carry one, so a broken line finding is rejected - // rather than quietly passing as a vaguer comment. - // file → filePath required; line ignored (defaults to 0). - // general → no file, no line (review-level; defaults to "" / 0). - let filePath = ""; - let lineStart = 0; - let lineEnd = 0; - if (scope !== "general") { - const fp = requireString(obj, "filePath", i); - if (typeof fp !== "string") return fp; - filePath = fp; - if (scope === "line") { - if (typeof obj.lineStart !== "number") { - return { error: `annotations[${i}] missing required "lineStart" field` }; - } - if (typeof obj.lineEnd !== "number") { - return { error: `annotations[${i}] missing required "lineEnd" field` }; - } - lineStart = obj.lineStart; - lineEnd = obj.lineEnd; - } else { - lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; - lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; - } - } - - // side: optional, defaults to "new" - const side = typeof obj.side === "string" ? obj.side : "new"; - if (!VALID_SIDES.includes(side)) { - return { - error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, - }; - } - - // type: optional, defaults to "comment" - const type = typeof obj.type === "string" ? obj.type : "comment"; - if (!VALID_REVIEW_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, - }; - } - - // Must have at least text or suggestedCode - if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { - return { - error: `annotations[${i}] must have at least one of: text, suggestedCode`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - type, - scope, - filePath, - lineStart, - lineEnd, - side, - text: typeof obj.text === "string" ? obj.text : undefined, - suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, - originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, - createdAt: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - // Agent review metadata (optional — only set by agent review findings) - ...(typeof obj.severity === "string" && { severity: obj.severity }), - ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), - ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Annotation Store (generic) -// --------------------------------------------------------------------------- - -type MutationListener = (event: ExternalAnnotationEvent) => void; - -export interface AnnotationStore { - /** Add fully-formed annotations. Returns the added annotations. */ - add(items: T[]): T[]; - /** Remove an annotation by ID. Returns true if found. */ - remove(id: string): boolean; - /** Remove all annotations from a specific source. Returns count removed. */ - clearBySource(source: string): number; - /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ - update(id: string, fields: Partial): T | null; - /** Remove all annotations. Returns count removed. */ - clearAll(): number; - /** Get all annotations (snapshot). */ - getAll(): T[]; - /** Monotonic version counter — incremented on every mutation. */ - readonly version: number; - /** Register a listener for mutation events. Returns unsubscribe function. */ - onMutation(listener: MutationListener): () => void; -} - -/** - * Create an in-memory annotation store. - * - * The store is runtime-agnostic — it holds data and emits events. - * HTTP transport (SSE broadcasting, request parsing) is handled by - * the server-specific adapter (Bun or Pi). - */ -export function createAnnotationStore(): AnnotationStore { - const annotations: T[] = []; - const listeners = new Set>(); - let version = 0; - - function emit(event: ExternalAnnotationEvent): void { - for (const listener of listeners) { - try { - listener(event); - } catch { - // Don't let a failing listener break the store - } - } - } - - return { - add(items) { - if (items.length > 0) { - for (const item of items) { - annotations.push(item); - } - version++; - emit({ type: "add", annotations: items }); - } - return items; - }, - - remove(id) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return false; - annotations.splice(idx, 1); - version++; - emit({ type: "remove", ids: [id] }); - return true; - }, - - update(id, fields) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return null; - const merged = { ...annotations[idx], ...fields, id } as T; - annotations[idx] = merged; - version++; - emit({ type: "update", id, annotation: merged }); - return merged; - }, - - clearBySource(source) { - const before = annotations.length; - for (let i = annotations.length - 1; i >= 0; i--) { - if (annotations[i].source === source) { - annotations.splice(i, 1); - } - } - const removed = before - annotations.length; - if (removed > 0) { - version++; - emit({ type: "clear", source }); - } - return removed; - }, - - clearAll() { - const count = annotations.length; - if (count > 0) { - annotations.length = 0; - version++; - emit({ type: "clear" }); - } - return count; - }, - - getAll() { - return [...annotations]; - }, - - get version() { - return version; - }, - - onMutation(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} +export * from '@plannotator/core/external-annotation'; diff --git a/packages/shared/extract-code-paths.ts b/packages/shared/extract-code-paths.ts index c4ef380fd..98aefed88 100644 --- a/packages/shared/extract-code-paths.ts +++ b/packages/shared/extract-code-paths.ts @@ -1,66 +1 @@ -import { - CODE_PATH_BARE_REGEX, - isCodeFilePath, - isCodeFilePathStrict, -} from "./code-file"; - -const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; -const HTML_COMMENT = //g; -// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised -// here mirror the ranges the renderer would consume. -const URL_REGEX = /https?:\/\/[^\s<>"']+/g; -const BACKTICK_SPAN = /`([^`\n]+)`/g; - -/** - * Extract candidate code-file paths from markdown text. Mirrors the renderer's - * detection precedence so the validator only sees paths the renderer would - * actually linkify: - * 1. fenced code blocks and HTML comments are stripped first; - * 2. URL ranges are excised before the bare-prose scan (URLs win); - * 3. backtick spans matching `isCodeFilePath` are collected; - * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and - * `isCodeFilePathStrict` are collected. - * - * Hash anchors (`#L42`) are stripped from results to match the renderer's - * `cleanPath` transform. Returns deduped candidate strings. - */ -export function extractCandidateCodePaths(markdown: string): string[] { - const stripped = markdown - .replace(FENCED_CODE_BLOCK, "") - .replace(HTML_COMMENT, ""); - - const candidates = new Set(); - - let m: RegExpExecArray | null; - const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); - while ((m = backtickRe.exec(stripped)) !== null) { - const inner = m[1].trim(); - if (isCodeFilePath(inner)) { - candidates.add(inner.replace(/#.*$/, "")); - } - } - - for (const line of stripped.split("\n")) { - const urlRanges: Array<[number, number]> = []; - const urlRe = new RegExp(URL_REGEX.source, "g"); - while ((m = urlRe.exec(line)) !== null) { - urlRanges.push([m.index, m.index + m[0].length]); - } - - const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); - while ((m = pathRe.exec(line)) !== null) { - const start = m.index; - const end = start + m[0].length; - const prev = start === 0 ? "" : line[start - 1]; - if (/\w/.test(prev)) continue; - const overlapsUrl = urlRanges.some( - ([s, e]) => start < e && end > s, - ); - if (overlapsUrl) continue; - if (!isCodeFilePathStrict(m[0])) continue; - candidates.add(m[0].replace(/#.*$/, "")); - } - } - - return Array.from(candidates); -} +export * from '@plannotator/core/extract-code-paths'; diff --git a/packages/shared/favicon.ts b/packages/shared/favicon.ts index c857b8419..c5c6f94c0 100644 --- a/packages/shared/favicon.ts +++ b/packages/shared/favicon.ts @@ -1,5 +1 @@ -export const FAVICON_SVG = ` - - - P -`; +export * from '@plannotator/core/favicon'; diff --git a/packages/shared/feedback-templates.ts b/packages/shared/feedback-templates.ts index 02d9b3217..7ba12f70e 100644 --- a/packages/shared/feedback-templates.ts +++ b/packages/shared/feedback-templates.ts @@ -1,45 +1 @@ -/** - * Shared feedback templates for all agent integrations. - * - * The plan deny template was tuned in #224 / commit 3dca977 to use strong - * directive framing — Claude was ignoring softer phrasing. - * - * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is - * bundled into the browser SPA. It must NOT import from ./prompts or ./config - * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. - * - * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. - * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. - */ - -export interface PlanDenyFeedbackOptions { - planFilePath?: string; -} - -export interface AnnotateFileFeedbackOptions { - filePath: string; - fileHeader?: "File" | "Folder" | string; -} - -export const planDenyFeedback = ( - feedback: string, - toolName: string = "ExitPlanMode", - options?: PlanDenyFeedbackOptions, -): string => { - const planFileRule = options?.planFilePath - ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` - : ""; - - return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; -}; - -export const annotateFileFeedback = ( - feedback: string, - options: AnnotateFileFeedbackOptions, -): string => { - const fileHeader = options.fileHeader ?? "File"; - return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; -}; - -export const annotateMessageFeedback = (feedback: string): string => - `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; +export * from '@plannotator/core/feedback-templates'; diff --git a/packages/shared/goal-setup.ts b/packages/shared/goal-setup.ts index 0b07f9c40..bd1dfb09d 100644 --- a/packages/shared/goal-setup.ts +++ b/packages/shared/goal-setup.ts @@ -1,336 +1 @@ -export type GoalSetupStage = "interview" | "facts"; - -export type GoalSetupAnswerMode = - | "text" - | "single" - | "multi" - | "single-custom" - | "multi-custom" - | "custom"; - -export interface GoalSetupQuestionOption { - id: string; - label: string; - description?: string; -} - -export interface GoalSetupQuestion { - id: string; - prompt: string; - description?: string; - answerMode?: GoalSetupAnswerMode; - recommendedAnswer?: string; - recommendedOptionIds?: string[]; - options?: GoalSetupQuestionOption[]; - required?: boolean; -} - -export interface GoalSetupQuestionAnswer { - questionId: string; - selectedOptionIds: string[]; - customAnswer: string; - note?: string; - answer: string; - completed: boolean; - skipped?: boolean; -} - -export interface GoalSetupInterviewBundle { - stage: "interview"; - title?: string; - goalSlug?: string; - questions: GoalSetupQuestion[]; -} - -export interface GoalSetupFact { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - recommendedAutomatedVerification?: boolean; - automatedVerification: boolean; - previousText?: string; -} - -export interface GoalSetupFactsBundle { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFact[]; - showAccepted?: boolean; -} - -export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; - -export interface GoalSetupInterviewResult { - stage: "interview"; - title?: string; - goalSlug?: string; - answers: GoalSetupQuestionAnswer[]; -} - -export interface GoalSetupFactResult { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - automatedVerification: boolean; - recommendedAutomatedVerification?: boolean; -} - -export interface GoalSetupFactsResult { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFactResult[]; - factsMarkdown: string; -} - -export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; - -function asRecord(value: unknown, context: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`${context} must be an object`); - } - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizeId(value: unknown, fallback: string): string { - const raw = asString(value, fallback).trim(); - return raw || fallback; -} - -function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { - switch (value) { - case "single": - case "multi": - case "single-custom": - case "multi-custom": - case "custom": - case "text": - return value; - default: - return "text"; - } -} - -function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { - const item = asRecord(value, `questions[].options[${index}]`); - const label = asString(item.label).trim(); - if (!label) { - throw new Error(`questions[].options[${index}].label is required`); - } - return { - id: normalizeId(item.id, `option-${index + 1}`), - label, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - }; -} - -function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { - const item = asRecord(value, `questions[${index}]`); - const prompt = asString(item.prompt).trim(); - if (!prompt) { - throw new Error(`questions[${index}].prompt is required`); - } - const options = Array.isArray(item.options) - ? item.options.map(normalizeOption) - : undefined; - - const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) - ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') - : undefined; - - return { - id: normalizeId(item.id, `question-${index + 1}`), - prompt, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - answerMode: normalizeAnswerMode(item.answerMode), - ...(asString(item.recommendedAnswer).trim() - ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } - : {}), - ...(recommendedOptionIds && recommendedOptionIds.length > 0 - ? { recommendedOptionIds } - : {}), - ...(options && options.length > 0 ? { options } : {}), - required: asBoolean(item.required, true), - }; -} - -function normalizeFact(value: unknown, index: number): GoalSetupFact { - const item = asRecord(value, `facts[${index}]`); - const text = asString(item.text).trim(); - if (!text) { - throw new Error(`facts[${index}].text is required`); - } - const recommended = asBoolean(item.recommendedAutomatedVerification, false); - return { - id: normalizeId(item.id, `fact-${index + 1}`), - text, - accepted: asBoolean(item.accepted, false), - removed: asBoolean(item.removed, false), - ...(asString(item.comment).trim() - ? { comment: asString(item.comment).trim() } - : {}), - recommendedAutomatedVerification: recommended, - automatedVerification: asBoolean(item.automatedVerification, recommended), - ...(asString(item.previousText).trim() - ? { previousText: asString(item.previousText).trim() } - : {}), - }; -} - -export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { - const raw = asRecord(value, "goal setup interview bundle"); - if (!Array.isArray(raw.questions) || raw.questions.length === 0) { - throw new Error("interview bundle requires at least one question"); - } - return { - stage: "interview", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - questions: raw.questions.map(normalizeQuestion), - }; -} - -export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { - const raw = asRecord(value, "goal setup facts bundle"); - if (!Array.isArray(raw.facts)) { - throw new Error("facts bundle requires a facts array"); - } - return { - stage: "facts", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - facts: raw.facts.map(normalizeFact), - showAccepted: asBoolean(raw.showAccepted, false), - }; -} - -export function normalizeGoalSetupBundle( - value: unknown, - expectedStage?: GoalSetupStage -): GoalSetupBundle { - const raw = asRecord(value, "goal setup bundle"); - const stage = expectedStage ?? raw.stage; - if (stage === "interview") return normalizeInterviewBundle(raw); - if (stage === "facts") return normalizeFactsBundle(raw); - throw new Error("goal setup bundle stage must be interview or facts"); -} - -export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { - return ( - answer.selectedOptionIds.length > 0 || - answer.customAnswer.trim().length > 0 || - answer.answer.trim().length > 0 - ); -} - -export function createInterviewResult( - bundle: GoalSetupInterviewBundle, - answers: GoalSetupQuestionAnswer[] -): GoalSetupInterviewResult { - const byId = new Map(answers.map((answer) => [answer.questionId, answer])); - return { - stage: "interview", - title: bundle.title, - goalSlug: bundle.goalSlug, - answers: bundle.questions.map((question) => { - const answer = byId.get(question.id); - const normalized: GoalSetupQuestionAnswer = { - questionId: question.id, - selectedOptionIds: Array.isArray(answer?.selectedOptionIds) - ? answer!.selectedOptionIds - : [], - customAnswer: asString(answer?.customAnswer), - ...(asString(answer?.note).trim() - ? { note: asString(answer?.note).trim() } - : {}), - answer: asString(answer?.answer), - completed: asBoolean(answer?.completed, false), - }; - const completed = normalized.completed || hasQuestionAnswer(normalized); - const skipped = asBoolean(answer?.skipped, false) && !completed; - return { - ...normalized, - completed, - ...(skipped ? { skipped: true } : {}), - }; - }), - }; -} - -export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { - if (bundle.showAccepted) return bundle.facts; - return bundle.facts.filter((fact) => !fact.accepted); -} - -export function createFactsResult( - bundle: GoalSetupFactsBundle, - facts: GoalSetupFactResult[] -): GoalSetupFactsResult { - const byId = new Map(facts.map((fact) => [fact.id, fact])); - const merged = bundle.facts.map((fact) => { - const next = byId.get(fact.id); - const removed = asBoolean(next?.removed, fact.removed); - const text = asString(next?.text, fact.text).trim(); - if (!removed && !text) { - throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); - } - const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") - ? asString(next.comment) - : asString(fact.comment) - ).trim(); - return { - id: fact.id, - text: text || fact.text, - accepted: asBoolean(next?.accepted, fact.accepted), - removed, - ...(comment ? { comment } : {}), - automatedVerification: asBoolean( - next?.automatedVerification, - fact.automatedVerification - ), - recommendedAutomatedVerification: - next?.recommendedAutomatedVerification ?? - fact.recommendedAutomatedVerification, - }; - }); - - return { - stage: "facts", - title: bundle.title, - goalSlug: bundle.goalSlug, - facts: merged, - factsMarkdown: factsResultToMarkdown(merged), - }; -} - -export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { - const accepted = facts.filter((fact) => fact.accepted && !fact.removed); - if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; - - const lines = ["# Facts", ""]; - for (const fact of accepted) { - lines.push(`- ${fact.text}`); - } - return lines.join("\n"); -} +export * from '@plannotator/core/goal-setup'; diff --git a/packages/shared/open-in-apps.ts b/packages/shared/open-in-apps.ts index 122af8feb..25d8ae092 100644 --- a/packages/shared/open-in-apps.ts +++ b/packages/shared/open-in-apps.ts @@ -1,189 +1 @@ -/** - * Open-in-App Catalog — single source of truth. - * - * Shared between the Bun/Pi servers (which launch the app) and the UI (which - * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure - * data + types only. - * - * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the - * app per platform: - * - mac.appName -> `open -a "" ` - * - win.bin -> ` ` (resolved against PATH) - * - linux.bin -> ` ` - * - * `kind` drives launch semantics: - * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, - * linux: open the parent dir) - * - editor -> open the file itself - * - terminal -> open the file's parent directory - * - * One special id has no platform launch fields: - * - 'reveal' (kind file-manager) — uses the OS file manager - */ - -export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; - -export interface OpenInApp { - /** Stable identifier persisted in the cookie + sent to the server. */ - id: string; - /** Human-readable label. For 'reveal' this is resolved per-platform. */ - label: string; - kind: OpenInKind; - /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ - icon: string; - /** macOS application bundle/display name passed to `open -a`. */ - mac?: { appName: string }; - /** Windows PATH binary. */ - win?: { bin: string }; - /** Linux PATH binary. */ - linux?: { bin: string }; -} - -/** - * The catalog, in menu order. The UI groups by `kind` - * (file-manager + default first, then editors, then terminals). - */ -export const OPEN_IN_APPS: OpenInApp[] = [ - // ── File manager (always available) ──────────────────────────────────── - { - id: 'reveal', - label: 'Finder', // resolved per-platform, see resolveRevealLabel - kind: 'file-manager', - icon: 'finder', // resolved per-platform, see resolveRevealIcon - }, - - // ── Editors ──────────────────────────────────────────────────────────── - { - id: 'vscode', - label: 'VS Code', - kind: 'editor', - icon: 'vscode', - mac: { appName: 'Visual Studio Code' }, - win: { bin: 'code' }, - linux: { bin: 'code' }, - }, - { - id: 'cursor', - label: 'Cursor', - kind: 'editor', - icon: 'cursor', - mac: { appName: 'Cursor' }, - win: { bin: 'cursor' }, - linux: { bin: 'cursor' }, - }, - { - id: 'zed', - label: 'Zed', - kind: 'editor', - icon: 'zed', - mac: { appName: 'Zed' }, - win: { bin: 'zed' }, - linux: { bin: 'zed' }, - }, - { - id: 'sublime-text', - label: 'Sublime Text', - kind: 'editor', - icon: 'sublime-text', - mac: { appName: 'Sublime Text' }, - win: { bin: 'subl' }, - linux: { bin: 'subl' }, - }, - { - id: 'textmate', - label: 'TextMate', - kind: 'editor', - icon: 'textmate', - mac: { appName: 'TextMate' }, - }, - { - id: 'antigravity', - label: 'Antigravity', - kind: 'editor', - icon: 'antigravity', - mac: { appName: 'Antigravity' }, - }, - { - id: 'xcode', - label: 'Xcode', - kind: 'editor', - icon: 'xcode', - mac: { appName: 'Xcode' }, - }, - { - id: 'android-studio', - label: 'Android Studio', - kind: 'editor', - icon: 'android-studio', - mac: { appName: 'Android Studio' }, - }, - - // ── Terminals ────────────────────────────────────────────────────────── - { - id: 'terminal', - label: 'Terminal', - kind: 'terminal', - icon: 'terminal', - mac: { appName: 'Terminal' }, - }, - { - id: 'iterm2', - label: 'iTerm2', - kind: 'terminal', - icon: 'iterm2', - mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" - }, - { - id: 'ghostty', - label: 'Ghostty', - kind: 'terminal', - icon: 'ghostty', - mac: { appName: 'Ghostty' }, - }, - { - id: 'warp', - label: 'Warp', - kind: 'terminal', - icon: 'warp', - mac: { appName: 'Warp' }, - }, - { - id: 'powershell', - label: 'PowerShell', - kind: 'terminal', - icon: 'powershell', - win: { bin: 'powershell' }, - }, -]; - -export type OpenInPlatform = 'mac' | 'win' | 'linux'; - -/** - * Per-platform label for the 'reveal' (file-manager) entry. - */ -export function resolveRevealLabel(platform: OpenInPlatform): string { - switch (platform) { - case 'win': - return 'Explorer'; - case 'linux': - return 'Files'; - case 'mac': - default: - return 'Finder'; - } -} - -/** - * Per-platform icon for the 'reveal' (file-manager) entry: - * finder on mac/linux, file-explorer on win. - */ -export function resolveRevealIcon(platform: OpenInPlatform): string { - return platform === 'win' ? 'file-explorer' : 'finder'; -} - -/** - * Look up a catalog entry by id. - */ -export function getOpenInApp(id: string): OpenInApp | undefined { - return OPEN_IN_APPS.find((app) => app.id === id); -} +export * from '@plannotator/core/open-in-apps'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 76aaefb10..4501cb074 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,7 @@ "./review-profiles": "./review-profiles.ts" }, "dependencies": { + "@plannotator/core": "workspace:*", "@joplin/turndown-plugin-gfm": "^1.0.64", "parse5": "^7.3.0", "turndown": "^7.2.4" diff --git a/packages/shared/project.ts b/packages/shared/project.ts index 23c130e81..ff1bbb489 100644 --- a/packages/shared/project.ts +++ b/packages/shared/project.ts @@ -1,71 +1 @@ -/** - * Project Utility — Pure Functions - * - * String sanitization and path extraction helpers. - * Runtime-agnostic: no Bun or Node-specific APIs. - */ - -/** - * Sanitize a string for use as a tag - * - lowercase - * - replace spaces/underscores with hyphens - * - remove special characters - * - trim to reasonable length - */ -export function sanitizeTag(name: string): string | null { - if (!name || typeof name !== "string") return null; - - const sanitized = name - .toLowerCase() - .trim() - .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens - .replace(/[^a-z0-9-]/g, "") // remove special chars - .replace(/-+/g, "-") // collapse multiple hyphens - .replace(/^-|-$/g, "") // trim leading/trailing hyphens - .slice(0, 30); // max 30 chars - - return sanitized.length >= 2 ? sanitized : null; -} - -/** - * Extract repo name from a git root path - */ -export function extractRepoName(gitRootPath: string): string | null { - if (!gitRootPath || typeof gitRootPath !== "string") return null; - - const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - return sanitizeTag(name); -} - -/** - * Extract directory name from a path - */ -export function extractDirName(path: string): string | null { - if (!path || typeof path !== "string") return null; - - const trimmed = path.trim().replace(/\/+$/, ""); - if (trimmed === "" || trimmed === "/") return null; - - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - // Skip generic names - const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); - if (skipNames.has(name.toLowerCase())) return null; - - return sanitizeTag(name); -} - -/** - * Extract hostname from a URL string, or return the original string on failure. - */ -export function hostnameOrFallback(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } -} +export * from '@plannotator/core/project'; diff --git a/packages/shared/source-save.ts b/packages/shared/source-save.ts index 434f72183..d36a116c3 100644 --- a/packages/shared/source-save.ts +++ b/packages/shared/source-save.ts @@ -1,138 +1 @@ -export type SourceSaveLanguage = "markdown" | "mdx" | "text"; - -export type SourceSaveDisabledReason = - | "not-annotate-mode" - | "not-local-file" - | "unsupported-extension" - | "converted-source" - | "html-render" - | "folder-mode" - | "message-mode" - | "shared-session" - | "missing-file" - | "unreadable-file"; - -export type SourceSaveScope = "single-file" | "folder-file"; - -export type SourceFileEol = "lf" | "crlf" | "mixed" | "none"; - -export interface SourceFileSnapshot { - text: string; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; -} - -export type SourceSaveCapability = - | { - enabled: true; - kind: "local-text-file"; - scope: SourceSaveScope; - path: string; - basename: string; - language: SourceSaveLanguage; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - enabled: false; - reason: SourceSaveDisabledReason; - }; - -export interface SourceSaveRequest { - path?: string; - text: string; - baseHash: string; - baseMtimeMs?: number; - baseEol?: SourceFileEol; - allowMissingBase?: boolean; -} - -export type SourceSaveResponse = - | { - ok: true; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - ok: false; - code: "conflict"; - message: string; - currentText: string; - currentHash: string; - currentMtimeMs: number; - currentSize: number; - currentEol: SourceFileEol; - } - | { - ok: false; - code: "not-writable" | "write-failed" | "invalid-request"; - message: string; - }; - -export type SourceSaveConflictResponse = Extract; - -export function isSourceFileEol(value: unknown): value is SourceFileEol { - return value === "lf" || value === "crlf" || value === "mixed" || value === "none"; -} - -export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): response is SourceSaveConflictResponse { - if (!("code" in response) || response.code !== "conflict") return false; - const conflict = response as SourceSaveConflictResponse; - return ( - typeof conflict.currentText === "string" && - typeof conflict.currentHash === "string" && - typeof conflict.currentMtimeMs === "number" && - typeof conflict.currentSize === "number" && - isSourceFileEol(conflict.currentEol) - ); -} - -export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; - -export function isSourceSaveFilePath(filePath: string): boolean { - return SOURCE_SAVE_FILE_REGEX.test(filePath); -} - -export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | null { - const lower = filePath.toLowerCase(); - if (lower.endsWith(".mdx")) return "mdx"; - if (lower.endsWith(".md")) return "markdown"; - if (lower.endsWith(".txt")) return "text"; - return null; -} - -export function basenameFromPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - return normalized.split("/").pop() || filePath; -} - -export function disabledSourceSave(reason: SourceSaveDisabledReason): SourceSaveCapability { - return { enabled: false, reason }; -} - -export function enabledSourceSave( - scope: SourceSaveScope, - filePath: string, - snapshot: SourceFileSnapshot, -): SourceSaveCapability { - const language = getSourceSaveLanguage(filePath); - if (!language) return disabledSourceSave("unsupported-extension"); - return { - enabled: true, - kind: "local-text-file", - scope, - path: filePath, - basename: basenameFromPath(filePath), - language, - hash: snapshot.hash, - mtimeMs: snapshot.mtimeMs, - size: snapshot.size, - eol: snapshot.eol, - }; -} +export * from '@plannotator/core/source-save'; diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index df8c958ae..431198d93 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -104,14 +104,8 @@ export function saveFinalSnapshot( // --- Plan Archive --- -export interface ArchivedPlan { - filename: string; - title: string; - date: string; - timestamp: string; // ISO string from file mtime - status: "approved" | "denied" | "unknown"; - size: number; -} +import type { ArchivedPlan } from '@plannotator/core/storage-types'; +export type { ArchivedPlan }; /** * Parse an archive filename into metadata. diff --git a/packages/shared/types.ts b/packages/shared/types.ts index dcb76c38f..eb20c5d65 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -1,13 +1,4 @@ -// Editor annotations from VS Code extension (ephemeral, in-memory only) -export interface EditorAnnotation { - id: string; - filePath: string; // workspace-relative (e.g., "src/auth.ts") - selectedText: string; - lineStart: number; // 1-based - lineEnd: number; // 1-based - comment?: string; - createdAt: number; -} +export type { EditorAnnotation } from '@plannotator/core/types'; // Git review types shared between server and client export type { diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 6d5c9ce62..5dbc2ab4a 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -3,45 +3,8 @@ import { existsSync, realpathSync } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; -export type WorkspaceFileStatus = - | "modified" - | "added" - | "deleted" - | "renamed" - | "copied" - | "typechange" - | "conflicted" - | "untracked"; - -export interface WorkspaceFileChange { - path: string; - repoRelativePath: string; - oldPath?: string; - status: WorkspaceFileStatus; - additions: number; - deletions: number; - staged: boolean; - unstaged: boolean; -} - -export interface WorkspaceStatusPayload { - available: boolean; - rootPath: string; - repoRoot?: string; - files: Record; - totals: { - files: number; - additions: number; - deletions: number; - }; - error?: string; -} - -export interface GitRepositoryInfo { - repoRoot: string; - gitDir: string; - gitCommonDir: string; -} +import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; +export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; const TEXT_FILE_MAX_BYTES = 2 * 1024 * 1024; const GIT_MAX_BUFFER = 20 * 1024 * 1024; diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 000000000..62823cb12 --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,10 @@ +# Working on `@plannotator/ui` + +This is the **published, reusable document UI** (`@plannotator/ui` + `@plannotator/core`). The commercial Workspaces app installs it and plugs in its own backend; Plannotator uses the defaults. See **`README.md`** in this directory for the architecture (packages, seams, `configurePlannotatorUI`, publishing). + +**The rules when editing here:** + +- **Do not reimplement the document UI from scratch.** A prior from-scratch rewrite broke the app and was reverted. +- To support a host's different backend, **add an optional seam** (a module-level `setX`/`resetX` default, or an optional prop) whose default reproduces today's behavior. Plannotator passes nothing and stays **byte-for-byte unchanged**. +- `@plannotator/core` is browser-safe and zero-dep — **no `node:` imports** (CI enforces it). `@plannotator/shared`/`@plannotator/ai` stay private; `shared` re-exports `core` via shims. +- **Never delete working Plannotator code until a human confirms parity in the browser.** diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/ui/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..240172ac2 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,59 @@ +# @plannotator/ui + +Plannotator's document UI — markdown rendering, themes, the annotation editor, settings, comments, and layout — as installable building blocks. Published so a separate app (the commercial Workspaces app) can reuse the exact same experience, while Plannotator itself stays unchanged. + +Ships with **`@plannotator/core`**: a small, browser-safe, zero-dependency package of the pure utilities and types `ui` builds on (carved out so `ui` can be installed standalone without Plannotator's server code). + +## Why this exists + +Workspaces needs the same document experience Plannotator has — render docs, annotate, comment, theme, edit — but backed by its own infrastructure (its own storage, auth, realtime, AI). Rather than fork or rebuild, it **installs these packages and plugs in its own backend.** Plannotator passes nothing and behaves exactly as before. + +## How it works: host-override seams + +Every place the UI talks to a backend (loading a doc preview, saving settings, persisting drafts, streaming comments, listing files, calling AI, etc.) is an **optional seam** that defaults to Plannotator's behavior. A host swaps in its own implementations through **one call at startup**: + +```ts +import { configurePlannotatorUI } from "@plannotator/ui/configure"; + +configurePlannotatorUI({ + storageBackend, // where settings persist + identityProvider, // who the current user is + imageSrcResolver, // how image paths resolve to URLs + docPreviewFetcher, + fileTreeBackend, + draftTransport, + externalAnnotationTransport, // live/agent comments + aiTransport, + serverSync, +}); +``` + +Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component. + +## Consuming it (e.g. from Workspaces) + +```bash +npm install @plannotator/ui @plannotator/core +``` + +1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. +2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). +3. **Load the fonts in your app entry** — the stylesheet references `--font-sans` / `--font-mono` but does not ship font binaries (standard for a shared UI package; your app owns font loading). Plannotator uses Inter + Geist Mono: + ```ts + import "@fontsource-variable/inter"; + import "@fontsource-variable/geist-mono"; + ``` + Or provide your own fonts and set `--font-sans` / `--font-mono` to match. +4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` +5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. + +## Packages & publishing + +- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published. +- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published. +- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched. +- Versioned in lockstep with the repo. Publish `core` then `ui` together with **`bun publish`** (not `npm` — bun resolves `workspace:*` to the exact version at pack time). + +## The one rule + +**Do not reimplement the document UI from scratch.** A prior from-scratch rewrite broke the app and was reverted. The supported path is always: keep these components as-is and add a seam where a host needs different backend behavior. Never delete working Plannotator code until a human has confirmed parity in the browser. diff --git a/packages/ui/components/AISettingsTab.tsx b/packages/ui/components/AISettingsTab.tsx index 6bd96be98..5010471a5 100644 --- a/packages/ui/components/AISettingsTab.tsx +++ b/packages/ui/components/AISettingsTab.tsx @@ -9,7 +9,7 @@ import { type AIProviderOption, } from '../utils/aiProvider'; import { useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface AIProvider extends AIProviderOption { capabilities: Record; diff --git a/packages/ui/components/AgentsTab.tsx b/packages/ui/components/AgentsTab.tsx index 37d5729bd..6035d5528 100644 --- a/packages/ui/components/AgentsTab.tsx +++ b/packages/ui/components/AgentsTab.tsx @@ -15,7 +15,7 @@ import { Search, } from 'lucide-react'; import type { AgentJobInfo, AgentCapabilities } from '../types'; -import { isTerminalStatus } from '@plannotator/shared/agent-jobs'; +import { isTerminalStatus } from '@plannotator/core/agent-jobs'; import { cn } from '../lib/utils'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { ClaudeIcon, CodexIcon } from './icons/AgentIcons'; diff --git a/packages/ui/components/DiffTypeSetupDialog.tsx b/packages/ui/components/DiffTypeSetupDialog.tsx index d6669eed6..c93d22b80 100644 --- a/packages/ui/components/DiffTypeSetupDialog.tsx +++ b/packages/ui/components/DiffTypeSetupDialog.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; -import type { DefaultDiffType } from '@plannotator/shared/config'; +import type { DefaultDiffType } from '@plannotator/core/config-types'; import { markDiffTypeSetupDone } from '../utils/diffTypeSetup'; import { configStore } from '../config'; import diffOptionsImg from '../assets/diff-options.png'; diff --git a/packages/ui/components/DocBadges.tsx b/packages/ui/components/DocBadges.tsx index cc7ecb1e6..2ac075b2e 100644 --- a/packages/ui/components/DocBadges.tsx +++ b/packages/ui/components/DocBadges.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import type { PlanDiffStats } from '../utils/planDiffEngine'; -import { hostnameOrFallback } from '@plannotator/shared/project'; +import { hostnameOrFallback } from '@plannotator/core/project'; import { OpenInAppButton } from './OpenInAppButton'; export interface LinkedDocBadgeInfo { diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 4d86e6fe1..3d1d28cd7 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -13,6 +13,28 @@ import { getOctarineSettings } from '../utils/octarine'; import { wrapFeedbackForAgent } from '../utils/parser'; import { OverlayScrollArea } from './OverlayScrollArea'; +/** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */ +interface SaveToNotesPayload { + obsidian?: object; + bear?: object; + octarine?: object; +} + +/** Parsed response from the notes endpoint. */ +interface SaveToNotesResult { + results?: Record; +} + +/** Default save-to-notes wire: today's literal POST to /api/save-notes. */ +async function defaultSaveToNotes(body: SaveToNotesPayload): Promise { + const res = await fetch('/api/save-notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); +} + interface ExportModalProps { isOpen: boolean; onClose: () => void; @@ -33,6 +55,8 @@ interface ExportModalProps { markdown?: string; isApiMode?: boolean; initialTab?: Tab; + /** Override the save-to-notes wire. Default: POST /api/save-notes (today's behavior). */ + onSaveToNotes?: (payload: SaveToNotesPayload) => Promise; } type Tab = 'share' | 'annotations' | 'notes'; @@ -56,6 +80,7 @@ export const ExportModal: React.FC = ({ markdown, isApiMode = false, initialTab, + onSaveToNotes = defaultSaveToNotes, }) => { const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); @@ -147,12 +172,7 @@ export const ExportModal: React.FC = ({ } try { - const res = await fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); + const data = await onSaveToNotes(body); const result = data.results?.[target]; if (result?.success) { diff --git a/packages/ui/components/ImageThumbnail.seam.test.tsx b/packages/ui/components/ImageThumbnail.seam.test.tsx new file mode 100644 index 000000000..56270357b --- /dev/null +++ b/packages/ui/components/ImageThumbnail.seam.test.tsx @@ -0,0 +1,103 @@ +/** + * Seam test: ImageSrcResolver override (setImageSrcResolver / resetImageSrcResolver). + * + * Contract: after setImageSrcResolver(fake), getImageSrc() (and thus the + * rendered img src) uses the fake resolver instead of the + * default /api/image endpoint. + * + * Primary assertion is via getImageSrc() (no DOM needed for the core contract). + * The DOM render assertion validates that the component wires getImageSrc. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as ImageThumbnailModule from './ImageThumbnail'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setImageSrcResolver with a no-op spy). +const setImageSrcResolver = ImageThumbnailModule.setImageSrcResolver; +const resetImageSrcResolver = ImageThumbnailModule.resetImageSrcResolver; +const getImageSrc = ImageThumbnailModule.getImageSrc; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetImageSrcResolver(); + if (hasDom) document.body.innerHTML = ''; +}); + +describe('ImageSrcResolver seam', () => { + test('fake resolver is called with the image path (via getImageSrc)', () => { + const calls: string[] = []; + const fakeResolver = (p: string) => { + calls.push(p); + return `https://cdn.example.com/images/${encodeURIComponent(p)}`; + }; + + setImageSrcResolver(fakeResolver); + + const result = getImageSrc('/foo/img.png'); + + expect(calls).toContain('/foo/img.png'); + expect(result).toContain('cdn.example.com'); + expect(result).toContain(encodeURIComponent('/foo/img.png')); + }); + + test('fake resolver receives the base parameter when provided', () => { + const calls: Array<{ path: string; base?: string }> = []; + const fake = (p: string, b?: string) => { + calls.push({ path: p, base: b }); + return `https://cdn.example.com/${p}`; + }; + + setImageSrcResolver(fake); + getImageSrc('relative/img.png', '/base/dir'); + + expect(calls[0]).toEqual({ path: 'relative/img.png', base: '/base/dir' }); + }); + + test('resetImageSrcResolver restores the default /api/image behavior', () => { + const fake = (p: string) => `https://cdn.example.com/${p}`; + setImageSrcResolver(fake); + resetImageSrcResolver(); + + // After reset, the default resolver builds /api/image?path=... URLs for local paths. + const result = getImageSrc('/my/photo.jpg'); + + expect(result).toContain('/api/image'); + expect(result).toContain(encodeURIComponent('/my/photo.jpg')); + expect(result).not.toContain('cdn.example.com'); + }); + + test('default resolver passes through remote URLs unchanged', () => { + // resetImageSrcResolver already called in afterEach; still on default after reset. + const remote = 'https://upload.example.com/images/foo.png'; + const result = getImageSrc(remote); + expect(result).toBe(remote); + }); + + test.skipIf(!hasDom)('rendered img src reflects the installed fake resolver', async () => { + const React = (await import('react')).default; + const { createRoot } = await import('react-dom/client'); + const { act } = await import('react'); + const { ImageThumbnail } = await import('./ImageThumbnail'); + + const calls: string[] = []; + setImageSrcResolver((p) => { calls.push(p); return `https://cdn.test/${encodeURIComponent(p)}`; }); + + const host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + const root = createRoot(host); + root.render(React.createElement(ImageThumbnail, { path: '/foo/img.png' })); + }); + + const img = host.querySelector('img'); + const src = img?.getAttribute('src') ?? ''; + + expect(calls).toContain('/foo/img.png'); + expect(src).toContain('cdn.test'); + }); +}); diff --git a/packages/ui/components/ImageThumbnail.tsx b/packages/ui/components/ImageThumbnail.tsx index 605c714cb..1b19ec716 100644 --- a/packages/ui/components/ImageThumbnail.tsx +++ b/packages/ui/components/ImageThumbnail.tsx @@ -1,9 +1,12 @@ import React, { useState } from 'react'; +export type ImageSrcResolver = (path: string, base?: string) => string; + /** - * Get the display URL for an image path or URL + * Default image URL resolver — Plannotator's local server behavior, verbatim. + * Remote URLs pass through; local paths resolve through `/api/image`. */ -export const getImageSrc = (path: string, base?: string): string => { +const defaultImageSrcResolver: ImageSrcResolver = (path, base) => { if (path.startsWith('http://') || path.startsWith('https://')) { return path; // Remote URL, use directly } @@ -14,6 +17,28 @@ export const getImageSrc = (path: string, base?: string): string => { return url; }; +// Module-level resolver, stable identity. Defaults to Plannotator's behavior so +// callers and consumers are unchanged. A host (e.g. Workspaces) calls +// `setImageSrcResolver` once at startup to resolve images via its own backend. +let imageSrcResolver: ImageSrcResolver = defaultImageSrcResolver; + +/** Override how image paths resolve to URLs. Call once at app startup. */ +export const setImageSrcResolver = (resolver: ImageSrcResolver): void => { + imageSrcResolver = resolver; +}; + +/** Reset to the default (Plannotator local) resolver. Mainly for tests. */ +export const resetImageSrcResolver = (): void => { + imageSrcResolver = defaultImageSrcResolver; +}; + +/** + * Get the display URL for an image path or URL. + * Delegates to the active resolver (default = Plannotator `/api/image`). + */ +export const getImageSrc = (path: string, base?: string): string => + imageSrcResolver(path, base); + interface ImageThumbnailProps { path: string; size?: 'sm' | 'md' | 'lg'; diff --git a/packages/ui/components/InlineMarkdown.seam.test.tsx b/packages/ui/components/InlineMarkdown.seam.test.tsx new file mode 100644 index 000000000..bd19de17d --- /dev/null +++ b/packages/ui/components/InlineMarkdown.seam.test.tsx @@ -0,0 +1,121 @@ +/** + * Seam test: DocPreviewFetcher override (setDocPreviewFetcher / resetDocPreviewFetcher). + * + * Contract: after setDocPreviewFetcher(fake), code-file hover previews use the + * fake fetcher instead of the default /api/doc. resetDocPreviewFetcher() restores + * the default. + * + * Requires DOM (happy-dom) — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as InlineMarkdownModule from './InlineMarkdown'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setDocPreviewFetcher with a no-op spy). +const setDocPreviewFetcher = InlineMarkdownModule.setDocPreviewFetcher; +const resetDocPreviewFetcher = InlineMarkdownModule.resetDocPreviewFetcher; +const InlineMarkdown = InlineMarkdownModule.InlineMarkdown; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetDocPreviewFetcher(); + if (hasDom) document.body.innerHTML = ''; +}); + +// The DocPreviewFetcher seam is exercised by the CodeFileLink component when its +// anchor element receives a mouseenter. Render a code-file path reference +// (src/index.ts:10 — has a line number so the hover is enabled) and fire the event. + +describe('DocPreviewFetcher seam', () => { + test.skipIf(!hasDom)('fake fetcher is called with the code-file path on hover', async () => { + const calls: Array<{ path: string; base?: string }> = []; + const fakeFetcher = async (path: string, base?: string) => { + calls.push({ path, base }); + return { contents: '// fake content', filepath: path }; + }; + + setDocPreviewFetcher(fakeFetcher); + + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root; + await act(async () => { + root = createRoot(host); + root.render( + {}} + />, + ); + }); + + // Find the rendered code-file link and fire the hover event. + // CodeFileLink renders a element with onMouseEnter. + // React 19 in happy-dom triggers onMouseEnter via mouseover (not mouseenter). + const codeLink = host.querySelector('code[role="button"]') as HTMLElement | null; + if (codeLink) { + await act(async () => { + // mouseover triggers React's onMouseEnter in happy-dom/React 19. + codeLink.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + // The hover delay is 150 ms; wait for it. + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + } + + // The fake fetcher must have been invoked with the code path. + expect(calls.length).toBeGreaterThan(0); + expect(calls[0].path).toContain('src/index.ts'); + }); + + test.skipIf(!hasDom)('resetDocPreviewFetcher restores the /api/doc default (does not call the fake)', async () => { + const calls: string[] = []; + const fake = async (path: string) => { calls.push(path); return null; }; + + setDocPreviewFetcher(fake); + resetDocPreviewFetcher(); + + // After reset, install a fetch spy so the default hits /api/doc + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify(null), { status: 200 }); + }) as typeof fetch; + + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root; + await act(async () => { + root = createRoot(host); + root.render( + {}} + />, + ); + }); + + const codeLink = host.querySelector('code[role="button"]') as HTMLElement | null; + if (codeLink) { + await act(async () => { + // Use mouseover — triggers React's onMouseEnter in happy-dom/React 19. + codeLink.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + } + + globalThis.fetch = realFetch; + + // Fake was NOT called (the reset restored the default); + // the default fetcher would have called /api/doc instead. + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 77617d930..9d4b63cb4 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,13 +1,44 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import hljs from "highlight.js"; -import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; +import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; import type { ValidationEntry } from "../hooks/useValidatedCodePaths"; import { CodeFilePicker } from "./CodeFilePicker"; +export interface DocPreviewResult { + contents?: string; + filepath?: string; +} +export type DocPreviewFetcher = (path: string, base?: string) => Promise; + +/** + * Default code-file hover-preview fetcher — Plannotator's `/api/doc` behavior, verbatim. + */ +const defaultDocPreviewFetcher: DocPreviewFetcher = async (path, base) => { + const params = new URLSearchParams({ path }); + if (base) params.set('base', base); + const res = await fetch(`/api/doc?${params}`); + return await res.json(); +}; + +// Module-level fetcher, stable identity. Defaults to Plannotator's `/api/doc`. +// A host (e.g. Workspaces) calls setDocPreviewFetcher once at startup to load +// hover previews from its own backend. +let docPreviewFetcher: DocPreviewFetcher = defaultDocPreviewFetcher; + +/** Override how code-file hover previews are fetched. Call once at app startup. */ +export const setDocPreviewFetcher = (fetcher: DocPreviewFetcher): void => { + docPreviewFetcher = fetcher; +}; + +/** Reset to the default (Plannotator `/api/doc`) fetcher. Mainly for tests. */ +export const resetDocPreviewFetcher = (): void => { + docPreviewFetcher = defaultDocPreviewFetcher; +}; + /** * Decide how a candidate code-file path should render based on validation state: * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput) @@ -149,11 +180,8 @@ const CodeFileLink: React.FC<{ if (hoverPreviewRef.current) return; showTimerRef.current = setTimeout(async () => { try { - const params = new URLSearchParams({ path: candidate }); - if (baseDir) params.set('base', baseDir); - const res = await fetch(`/api/doc?${params}`); - const data = await res.json(); - if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); + const data = await docPreviewFetcher(candidate, baseDir); + if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); diff --git a/packages/ui/components/MarkdownEditor.tsx b/packages/ui/components/MarkdownEditor.tsx index 23442c5ba..8d44e267e 100644 --- a/packages/ui/components/MarkdownEditor.tsx +++ b/packages/ui/components/MarkdownEditor.tsx @@ -24,17 +24,20 @@ interface MarkdownEditorProps { /** Mirrors the Viewer card's outer maxWidth so toggling view<->edit doesn't jump. */ maxWidth?: number | null; gridEnabled?: boolean; + /** Theme color mode. Defaults to the ThemeProvider's resolved mode (Plannotator + passes nothing); a host without ThemeProvider can supply it directly. */ + mode?: React.ComponentProps['mode']; } /* Theme-bridging shim around @plannotator/markdown-editor. App.tsx renders its ThemeProvider inside its own JSX, so the resolved color mode must be read from a component beneath the provider — here — and passed down as a prop. */ -export const MarkdownEditor: React.FC = ({ gridEnabled, ...props }) => { +export const MarkdownEditor: React.FC = ({ gridEnabled, mode, ...props }) => { const { resolvedMode } = useTheme(); return ( ); diff --git a/packages/ui/components/MenuVersionSection.tsx b/packages/ui/components/MenuVersionSection.tsx index 9b31f9be3..2caa5ff60 100644 --- a/packages/ui/components/MenuVersionSection.tsx +++ b/packages/ui/components/MenuVersionSection.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { isWindows } from '../utils/platform'; const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension'; diff --git a/packages/ui/components/OpenInAppButton.tsx b/packages/ui/components/OpenInAppButton.tsx index 88c9d8d8b..4690e8708 100644 --- a/packages/ui/components/OpenInAppButton.tsx +++ b/packages/ui/components/OpenInAppButton.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react'; import { AppIcon } from './icons/AppIcon'; import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage'; -import type { OpenInKind } from '@plannotator/shared/open-in-apps'; +import type { OpenInKind } from '@plannotator/core/open-in-apps'; import { DropdownMenu, DropdownMenuTrigger, diff --git a/packages/ui/components/PlanAIAnnouncementDialog.tsx b/packages/ui/components/PlanAIAnnouncementDialog.tsx index f27a8dcbd..8dc599c46 100644 --- a/packages/ui/components/PlanAIAnnouncementDialog.tsx +++ b/packages/ui/components/PlanAIAnnouncementDialog.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/core/agents'; import { SparklesIcon } from './SparklesIcon'; import { getProviderMeta } from './ProviderIcons'; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index d5243b36e..2855ecfde 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -11,7 +11,7 @@ import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { MenuVersionSection } from './MenuVersionSection'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface PlanHeaderMenuProps { appVersion: string; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index d4ce85670..78347ec6d 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { Origin } from '@plannotator/core/agents'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { configStore, useConfigValue } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; @@ -87,6 +87,8 @@ interface SettingsProps { aiProviders?: Array<{ id: string; name: string; capabilities: Record; models?: Array<{ id: string; label: string; default?: boolean }> }>; /** Git user name from `git config user.name`, for quick identity set */ gitUser?: string; + /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */ + onDetectObsidianVaults?: () => Promise; } // --- Review-mode Display tab (diff display options) --- @@ -610,7 +612,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, externalOpen, onExternalClose, aiProviders = [], gitUser, onDetectObsidianVaults }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -745,13 +747,14 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange useEffect(() => { if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) { setVaultsLoading(true); - fetch('/api/obsidian/vaults') - .then(res => res.json()) - .then((data: { vaults: string[] }) => { - setDetectedVaults(data.vaults || []); + const detect = onDetectObsidianVaults + ?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || [])); + detect() + .then((vaults: string[]) => { + setDetectedVaults(vaults || []); // Auto-select first vault if none set - if (data.vaults?.length > 0 && !obsidian.vaultPath) { - handleObsidianChange({ vaultPath: data.vaults[0] }); + if (vaults?.length > 0 && !obsidian.vaultPath) { + handleObsidianChange({ vaultPath: vaults[0] }); } }) .catch(() => setDetectedVaults([])) diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 87339bb6b..099951145 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -75,6 +75,9 @@ 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; + /** Opt out of `/api/doc/exists` code-path validation (host without that + * endpoint). Default undefined for Plannotator => validation stays on. */ + disableCodePathValidation?: boolean; linkedDocInfo?: LinkedDocBadgeInfo | null; // Plan diff props planDiffStats?: { additions: number; deletions: number; modifications: number } | null; @@ -178,6 +181,7 @@ export const Viewer = forwardRef(({ linkedDocInfo, imageBaseDir, codePathBaseDir, + disableCodePathValidation, copyLabel, actionsLabelMode = 'full', archiveInfo, @@ -529,7 +533,7 @@ export const Viewer = forwardRef(({ setViewerCommentPopover(null); }, []); - const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation); return ( diff --git a/packages/ui/components/blocks/HtmlBlock.tsx b/packages/ui/components/blocks/HtmlBlock.tsx index d4e7ce3be..1fdac8094 100644 --- a/packages/ui/components/blocks/HtmlBlock.tsx +++ b/packages/ui/components/blocks/HtmlBlock.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from "react"; -import { isCodeFilePath } from "@plannotator/shared/code-file"; +import { isCodeFilePath } from "@plannotator/core/code-file"; import { Block } from "../../types"; import { sanitizeBlockHtml } from "../../utils/sanitizeHtml"; import { getImageSrc } from "../ImageThumbnail"; diff --git a/packages/ui/components/goal-setup/GoalSetupSurface.tsx b/packages/ui/components/goal-setup/GoalSetupSurface.tsx index fb16a0a81..7547ba468 100644 --- a/packages/ui/components/goal-setup/GoalSetupSurface.tsx +++ b/packages/ui/components/goal-setup/GoalSetupSurface.tsx @@ -17,7 +17,7 @@ import type { GoalSetupInterviewBundle, GoalSetupQuestion, GoalSetupQuestionAnswer, -} from '@plannotator/shared/goal-setup'; +} from '@plannotator/core/goal-setup'; import { ConfirmDialog } from '../ConfirmDialog'; import { CommentPopover } from '../CommentPopover'; import { Button } from '../core/button'; diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index 2830e8439..ae456297b 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -34,8 +34,28 @@ interface PlanDiffViewerProps { onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId?: string | null; mode?: EditorMode; + onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>; } +const defaultOpenVscodeDiff = async ( + baseVersion: number +): Promise<{ ok?: boolean; error?: string }> => { + try { + const res = await fetch("/api/plan/vscode-diff", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseVersion }), + }); + const data = (await res.json()) as { ok?: boolean; error?: string }; + if (!res.ok || data.error) { + return { error: data.error || "Failed to open VS Code diff" }; + } + return { ok: true }; + } catch { + return { error: "Failed to connect to server" }; + } +}; + export const PlanDiffViewer: React.FC = ({ diffBlocks, diffStats, @@ -51,6 +71,7 @@ export const PlanDiffViewer: React.FC = ({ onSelectAnnotation, selectedAnnotationId, mode, + onOpenVscodeDiff, }) => { const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false); const [vscodeDiffError, setVscodeDiffError] = useState(null); @@ -58,21 +79,18 @@ export const PlanDiffViewer: React.FC = ({ const canOpenVscodeDiff = baseVersion != null; const handleOpenVscodeDiff = async () => { - if (!canOpenVscodeDiff) return; + if (!canOpenVscodeDiff || baseVersion == null) return; setVscodeDiffLoading(true); setVscodeDiffError(null); try { - const res = await fetch("/api/plan/vscode-diff", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseVersion }), - }); - const data = await res.json() as { ok?: boolean; error?: string }; - if (!res.ok || data.error) { - setVscodeDiffError(data.error || "Failed to open VS Code diff"); + const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); + if (result.error) { + setVscodeDiffError(result.error); } } catch { - setVscodeDiffError("Failed to connect to server"); + // A host-supplied opener that throws (instead of returning { error }) must + // not wedge the button in a permanent loading state. + setVscodeDiffError("Failed to open VS Code diff"); } finally { setVscodeDiffLoading(false); } diff --git a/packages/ui/components/settings/HooksTab.tsx b/packages/ui/components/settings/HooksTab.tsx index 683ba3a59..3fb402ce7 100644 --- a/packages/ui/components/settings/HooksTab.tsx +++ b/packages/ui/components/settings/HooksTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { FAVICON_SVG } from '@plannotator/shared/favicon'; +import { FAVICON_SVG } from '@plannotator/core/favicon'; interface HooksStatus { pfmReminder: { enabled: boolean }; diff --git a/packages/ui/components/sidebar/ArchiveBrowser.tsx b/packages/ui/components/sidebar/ArchiveBrowser.tsx index 4505895a6..a96630ab4 100644 --- a/packages/ui/components/sidebar/ArchiveBrowser.tsx +++ b/packages/ui/components/sidebar/ArchiveBrowser.tsx @@ -6,7 +6,7 @@ */ import React from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; +import type { ArchivedPlan } from "@plannotator/core/storage-types"; export type { ArchivedPlan }; diff --git a/packages/ui/components/sidebar/FileBrowser.test.ts b/packages/ui/components/sidebar/FileBrowser.test.ts index c7884ab1e..ff00a513f 100644 --- a/packages/ui/components/sidebar/FileBrowser.test.ts +++ b/packages/ui/components/sidebar/FileBrowser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { VaultNode } from "../../types"; -import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; +import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; import { getAggregateWorkspaceChange, getFileEditStatus, getWorkspaceChange, isFileTreeSelectionDisabled, normalizePathForLookup } from "./FileBrowser"; describe("FileBrowser workspace status lookup", () => { diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 192cbfd1f..9b432c8f4 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -10,8 +10,8 @@ import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; import { ObsidianIconRaw } from "../icons/ObsidianIcons"; -import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; -import { normalizeBrowserPath } from "@plannotator/shared/browser-paths"; +import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; +import { normalizeBrowserPath } from "@plannotator/core/browser-paths"; interface FileBrowserProps { dirs: DirState[]; diff --git a/packages/ui/config/configStore.seam.test.ts b/packages/ui/config/configStore.seam.test.ts new file mode 100644 index 000000000..4d2fb0b90 --- /dev/null +++ b/packages/ui/config/configStore.seam.test.ts @@ -0,0 +1,110 @@ +/** + * Seam tests: ConfigStore server-sync override + loadFromBackend re-hydration. + * + * Test 1 (serverSync seam): configStore.set on a server-synced key calls the + * installed sync fn instead of the default POST /api/config. + * resetServerSync() restores the default fn. + * + * Test 2 (loadFromBackend seam): install a fake StorageBackend with a + * prefetched setting, call loadFromBackend() — the store now returns the + * prefetched value. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { configStore } from './index'; +import * as storageModule from '../utils/storage'; + +// Capture real storage functions at import time (before configure.test.ts's +// mock.module('./utils/storage', ...) replaces them with no-op spies). +const setStorageBackend = storageModule.setStorageBackend; +const resetStorageBackend = storageModule.resetStorageBackend; + +afterEach(() => { + configStore.resetServerSync(); + resetStorageBackend(); +}); + +// --------------------------------------------------------------------------- +// 1. serverSync seam +// --------------------------------------------------------------------------- +describe('configStore.setServerSync seam', () => { + it('routes server write-back through the installed sync fn', async () => { + const synced: Array> = []; + const fakeSync = (payload: Record) => { + synced.push(payload); + }; + + configStore.setServerSync(fakeSync); + + // 'displayName' is a server-synced setting (serverKey: 'displayName') + configStore.set('displayName', 'test-tater'); + + // Server write-back is debounced at 300 ms — wait for the timer to fire. + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(synced.length).toBeGreaterThanOrEqual(1); + // The payload must contain the displayName key from toServer(). + const merged = Object.assign({}, ...synced) as Record; + expect(merged).toHaveProperty('displayName', 'test-tater'); + }); + + it('resetServerSync() restores the default fn (no longer calls the fake)', async () => { + const fakeCalled: boolean[] = []; + configStore.setServerSync((_: Record) => { fakeCalled.push(true); }); + configStore.resetServerSync(); + + configStore.set('displayName', 'another-tater'); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(fakeCalled).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. loadFromBackend seam +// --------------------------------------------------------------------------- +describe('configStore.loadFromBackend seam', () => { + it('re-hydrates settings from the installed StorageBackend', () => { + // Install a fake StorageBackend that returns a specific displayName. + // settings.ts reads 'plannotator-identity' for the displayName setting. + const prefetched = new Map([ + ['plannotator-identity', 'prefetched-workspace-user'], + ]); + const fakeBackend = { + getItem: (key: string) => prefetched.get(key) ?? null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(fakeBackend); + configStore.loadFromBackend(); + + // The store should now reflect the prefetched value. + expect(configStore.get('displayName')).toBe('prefetched-workspace-user'); + }); + + it('keys absent from the backend are left at their prior value (not overwritten with undefined)', () => { + // First set a known value for 'displayName'. + configStore.set('displayName', 'prior-value'); + + // Install a backend that returns null for every key (simulates a backend + // that has no opinion on this setting). + const emptyBackend = { + getItem: (_key: string) => null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(emptyBackend); + configStore.loadFromBackend(); + + // 'prior-value' must be preserved — loadFromBackend only overwrites when + // fromCookie() returns a non-undefined result. + expect(configStore.get('displayName')).toBe('prior-value'); + }); +}); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 382afb65e..655c6f828 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -29,6 +29,18 @@ function deepMerge(target: Record, source: Record) => void; + +/** Default = today's inline POST /api/config (best-effort). */ +const defaultServerSync: ServerSyncFn = (payload) => { + fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); // best-effort +}; + /** Infer the value type from a SettingDef */ type SettingValue = SettingsMap[K] extends { defaultValue: infer D } ? D extends (...args: unknown[]) => infer R ? R : D @@ -40,6 +52,7 @@ class ConfigStore { private version = 0; private pendingServerWrites: Record = {}; private serverSyncTimer: ReturnType | null = null; + private serverSync: ServerSyncFn = defaultServerSync; constructor() { // Eagerly resolve all settings from synchronous sources (cookie > default). @@ -58,6 +71,25 @@ class ConfigStore { } } + /** + * Re-hydrate all settings from the currently installed StorageBackend. + * ADDITIVE host hook — Plannotator never calls this (eager cookie default unchanged). + * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls + * this to route the initial load through that backend. Precedence after a host call: + * server (init) > host backend (loadFromBackend) > cookie/default (constructor). + * Call this BEFORE init(serverConfig): init() always wins, so calling loadFromBackend() + * after init() would silently overwrite server-supplied settings. + */ + loadFromBackend(): void { + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } + } + this.notify(); + } + /** * Apply server config overrides. * Call once after fetching /api/plan or /api/diff. @@ -105,6 +137,13 @@ class ConfigStore { return () => this.listeners.delete(listener); } + /** Override the server write-back transport (default = inline POST /api/config). */ + setServerSync(fn: ServerSyncFn): void { + this.serverSync = fn; + } + + resetServerSync(): void { this.serverSync = defaultServerSync; } + private notify(): void { this.version++; for (const fn of this.listeners) fn(); @@ -115,11 +154,7 @@ class ConfigStore { this.serverSyncTimer = setTimeout(() => { const payload = { ...this.pendingServerWrites }; this.pendingServerWrites = {}; - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => {}); // best-effort + this.serverSync(payload); }, 300); } } diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 2251f86ee..2c951a9b8 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,7 +9,7 @@ * Add new settings here. Cookie-only settings omit serverKey. */ -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts new file mode 100644 index 000000000..3423e9b6d --- /dev/null +++ b/packages/ui/configure.test.ts @@ -0,0 +1,239 @@ +import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test'; + +import * as ImageThumbnail from './components/ImageThumbnail'; +import * as InlineMarkdown from './components/InlineMarkdown'; +import * as storage from './utils/storage'; +import * as identity from './utils/identity'; +import * as useFileBrowser from './hooks/useFileBrowser'; +import * as useAnnotationDraft from './hooks/useAnnotationDraft'; +import * as useExternalAnnotations from './hooks/useExternalAnnotations'; +import * as useAIChat from './hooks/useAIChat'; +import { configStore } from './config'; + +import type { ImageSrcResolver } from './components/ImageThumbnail'; +import type { DocPreviewFetcher } from './components/InlineMarkdown'; +import type { StorageBackend } from './utils/storage'; +import type { IdentityProvider } from './utils/identity'; +import type { FileTreeBackend } from './hooks/useFileBrowser'; +import type { DraftTransport } from './hooks/useAnnotationDraft'; +import type { ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import type { AITransport } from './hooks/useAIChat'; + +// Capture the REAL exports at module-evaluation time (top-level, before any +// mock.module() is installed). These are used to restore the module registry +// in afterAll so that any sibling test files run in the same Bun worker see +// the real exports when THEY evaluate after this file finishes. +const realSetImageSrcResolver = ImageThumbnail.setImageSrcResolver; +const realResetImageSrcResolver = ImageThumbnail.resetImageSrcResolver; +const realSetDocPreviewFetcher = InlineMarkdown.setDocPreviewFetcher; +const realResetDocPreviewFetcher = InlineMarkdown.resetDocPreviewFetcher; +const realSetStorageBackend = storage.setStorageBackend; +const realResetStorageBackend = storage.resetStorageBackend; +const realSetIdentityProvider = identity.setIdentityProvider; +const realResetIdentityProvider = identity.resetIdentityProvider; +const realSetFileTreeBackend = useFileBrowser.setFileTreeBackend; +const realResetFileTreeBackend = useFileBrowser.resetFileTreeBackend; +const realSetDraftTransport = useAnnotationDraft.setDraftTransport; +const realResetDraftTransport = useAnnotationDraft.resetDraftTransport; +const realSetExternalAnnotationTransport = useExternalAnnotations.setExternalAnnotationTransport; +const realResetExternalAnnotationTransport = useExternalAnnotations.resetExternalAnnotationTransport; +const realSetAITransport = useAIChat.setAITransport; +const realResetAITransport = useAIChat.resetAITransport; + +// Spy mocks — will be installed into the module registry in beforeAll. +const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); +const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); +const setStorageBackend = mock((_: StorageBackend) => {}); +const setIdentityProvider = mock((_: IdentityProvider) => {}); +const setFileTreeBackend = mock((_: FileTreeBackend) => {}); +const setDraftTransport = mock((_: DraftTransport) => {}); +const setExternalAnnotationTransport = mock((_: ExternalAnnotationTransport<{ id: string; source?: string }>) => {}); +const setAITransport = mock((_: AITransport) => {}); + +// configStore is shared with sibling suites — spy on the real instance methods +// instead of replacing the ./config module. +const setServerSync = spyOn(configStore, 'setServerSync'); +const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation(() => {}); + +// Shape-correct fakes (only need to satisfy the front door's optional fields). +const imageSrcResolver: ImageSrcResolver = (path) => path; +const docPreviewFetcher: DocPreviewFetcher = async () => null; +const storageBackend: StorageBackend = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +const identityProvider: IdentityProvider = { getIdentity: () => 'tater', isCurrentUser: () => false }; +const fileTreeBackend: FileTreeBackend = { + loadTree: async () => new Response('{}'), + loadVaultTree: async () => new Response('{}'), + watchTrees: () => undefined, +}; +const draftTransport: DraftTransport = { + load: async () => ({ data: null, generation: null }), + save: async () => {}, + remove: async () => {}, +}; +const externalAnnotationTransport: ExternalAnnotationTransport<{ id: string; source?: string }> = { + subscribe: () => () => {}, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, +}; +const aiTransport: AITransport = { + session: async () => new Response(), + query: async () => new Response(), + abort: () => {}, + permission: () => {}, +}; +const serverSync = (_payload: Record) => {}; + +describe('configurePlannotatorUI routing', () => { + // Install mock.module() replacements HERE (in beforeAll, not at top-level) + // so that sibling seam test files' top-level captures (which happen at module + // evaluation time, BEFORE this beforeAll runs) see the real exports. + // + // Bun runs test files sequentially: file A's top-level → file A's tests + // (including beforeAll/afterAll) → file B's top-level → file B's tests. + // Because configure.test.ts runs before the seam test files (alphabetical), + // the seam files evaluate AFTER this file's afterAll — so they see whatever + // state this afterAll leaves the module registry in. We MUST restore with + // captured real functions (not `{ ...storage }`) because spreading the live + // namespace after mock.module() returns the mocked version. + beforeAll(async () => { + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport, + resetAITransport: realResetAITransport, + })); + }); + + afterAll(() => { + mock.restore(); + // Restore using CAPTURED REAL FUNCTIONS (not `{ ...storage }` which would + // spread the mocked namespace and leave spies in place for sibling files). + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver: realSetImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher: realSetDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend: realSetStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider: realSetIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend: realSetFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport: realSetDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport: realSetExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport: realSetAITransport, + resetAITransport: realResetAITransport, + })); + }); + + it('routes each provided seam to its underlying setter', async () => { + const { configurePlannotatorUI } = await import('./configure'); + + configurePlannotatorUI({ + imageSrcResolver, + storageBackend, + docPreviewFetcher, + fileTreeBackend, + identityProvider, + draftTransport, + externalAnnotationTransport, + aiTransport, + serverSync, + loadSettingsFromBackend: true, + }); + + expect(setImageSrcResolver).toHaveBeenCalledWith(imageSrcResolver); + expect(setDocPreviewFetcher).toHaveBeenCalledWith(docPreviewFetcher); + expect(setStorageBackend).toHaveBeenCalledWith(storageBackend); + expect(setIdentityProvider).toHaveBeenCalledWith(identityProvider); + expect(setFileTreeBackend).toHaveBeenCalledWith(fileTreeBackend); + expect(setDraftTransport).toHaveBeenCalledWith(draftTransport); + expect(setExternalAnnotationTransport).toHaveBeenCalledWith(externalAnnotationTransport); + expect(setAITransport).toHaveBeenCalledWith(aiTransport); + expect(setServerSync).toHaveBeenCalledWith(serverSync); + expect(loadFromBackend).toHaveBeenCalledTimes(1); + + // load-bearing order: storageBackend installed before loadFromBackend re-hydrates. + expect(setStorageBackend.mock.invocationCallOrder[0]).toBeLessThan( + loadFromBackend.mock.invocationCallOrder[0], + ); + }); + + it('skips setters for omitted fields', async () => { + const { configurePlannotatorUI } = await import('./configure'); + + [ + setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, + setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, + setServerSync, loadFromBackend, + ].forEach((m) => m.mockClear()); + + configurePlannotatorUI({ storageBackend }); + + expect(setStorageBackend).toHaveBeenCalledTimes(1); + expect(setImageSrcResolver).not.toHaveBeenCalled(); + expect(setAITransport).not.toHaveBeenCalled(); + expect(loadFromBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts new file mode 100644 index 000000000..0f786e689 --- /dev/null +++ b/packages/ui/configure.ts @@ -0,0 +1,46 @@ +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setIdentityProvider, type IdentityProvider } from './utils/identity'; +import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; +import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; +import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import { setAITransport, type AITransport } from './hooks/useAIChat'; +import { configStore } from './config'; +import type { ServerSyncFn } from './config/configStore'; + +type ExternalAnnotationBase = { id: string; source?: string }; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + docPreviewFetcher?: DocPreviewFetcher; + fileTreeBackend?: FileTreeBackend; + identityProvider?: IdentityProvider; + draftTransport?: DraftTransport; + /** + * Base-constraint transport. If your annotation type extends the base + * constraint ({ id: string; source?: string }) with extra fields, call + * setExternalAnnotationTransport() directly for full type safety — + * this front-door field intentionally pins the base constraint for ergonomics. + */ + externalAnnotationTransport?: ExternalAnnotationTransport; + aiTransport?: AITransport; + serverSync?: ServerSyncFn; + /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ + loadSettingsFromBackend?: boolean; +} + +export function configurePlannotatorUI(config: PlannotatorUIConfig): void { + if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); + if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); + if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); + if (config.identityProvider) setIdentityProvider(config.identityProvider); + if (config.draftTransport) setDraftTransport(config.draftTransport); + if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); + if (config.aiTransport) setAITransport(config.aiTransport); + if (config.serverSync) configStore.setServerSync(config.serverSync); + // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). + if (config.loadSettingsFromBackend) configStore.loadFromBackend(); +} diff --git a/packages/ui/hooks/pfm/useCodeFilePopout.ts b/packages/ui/hooks/pfm/useCodeFilePopout.ts index fe7dfa08e..d2f58d4e2 100644 --- a/packages/ui/hooks/pfm/useCodeFilePopout.ts +++ b/packages/ui/hooks/pfm/useCodeFilePopout.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { parseCodePath } from "@plannotator/shared/code-file"; +import { parseCodePath } from "@plannotator/core/code-file"; interface CodeFileState { filepath: string; diff --git a/packages/ui/hooks/useAIChat.seam.test.tsx b/packages/ui/hooks/useAIChat.seam.test.tsx new file mode 100644 index 000000000..ba1ee5864 --- /dev/null +++ b/packages/ui/hooks/useAIChat.seam.test.tsx @@ -0,0 +1,151 @@ +/** + * Seam test: AITransport override (setAITransport / resetAITransport). + * + * Contract: + * - After setAITransport(fake), useAIChat.ask() routes the session + query + * calls through the fake transport — NOT through /api/ai/*. + * - resetAITransport() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useAIChatModule from './useAIChat'; +import type { AITransport } from './useAIChat'; +import type { AIContext } from '@plannotator/core'; + +// Capture real function references at import time. +const setAITransport = useAIChatModule.setAITransport; +const resetAITransport = useAIChatModule.resetAITransport; +const useAIChat = useAIChatModule.useAIChat; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetAITransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type HookResult = ReturnType; + +function Harness({ resultRef, context }: { resultRef: { current: HookResult | null }; context: AIContext | null }) { + resultRef.current = useAIChat({ context }); + return null; +} + +const TEST_CONTEXT: AIContext = { + mode: 'plan-review', + plan: { plan: 'Test plan content' }, +}; + +function makeSseResponse(textDelta: string): Response { + const body = `data: {"type":"text_delta","delta":"${textDelta}"}\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +async function mountHook(context: AIContext | null): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +describe('AITransport seam', () => { + test.skipIf(!hasDom)('fake session + query are called when useAIChat.ask() is invoked', async () => { + const sessionBodies: unknown[] = []; + const queryBodies: unknown[] = []; + + const fakeTransport: AITransport = { + session: async (body, _signal) => { + sessionBodies.push(body); + return new Response(JSON.stringify({ sessionId: 'fake-session-001' }), { status: 200 }); + }, + query: async (body, _signal) => { + queryBodies.push(body); + return makeSseResponse('hello'); + }, + abort: () => {}, + permission: () => {}, + }; + + setAITransport(fakeTransport); + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + await session.result.current!.ask({ prompt: 'What is this plan about?' }); + }); + + // Allow SSE reader to drain + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + expect(sessionBodies.length).toBeGreaterThanOrEqual(1); + expect(queryBodies.length).toBeGreaterThanOrEqual(1); + const qb = queryBodies[0] as Record; + expect(qb.sessionId).toBe('fake-session-001'); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetAITransport restores the default (does not call the fake)', async () => { + const fakeCalls: string[] = []; + const fake: AITransport = { + session: async () => { fakeCalls.push('session'); return new Response('{}', { status: 200 }); }, + query: async () => { fakeCalls.push('query'); return new Response('', { status: 200 }); }, + abort: () => { fakeCalls.push('abort'); }, + permission: () => { fakeCalls.push('permission'); }, + }; + + setAITransport(fake); + resetAITransport(); + + // After reset, the default transport issues a real fetch to /api/ai/session. + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ sessionId: 'real-session' }), { status: 200 }); + }) as typeof fetch; + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + // Fire-and-forget: we just want to trigger the session creation path. + session.result.current!.ask({ prompt: 'test' }).catch(() => {}); + }); + + // Give the async session call a tick to fire. + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + globalThis.fetch = realFetch; + + // Fake was NOT called; the default made a fetch to /api/ai/session. + expect(fakeCalls).toHaveLength(0); + expect(fetchCalls.some((u) => u.includes('/api/ai/session'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 6047743b2..50017e872 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { AIContext } from '@plannotator/ai'; +import type { AIContext } from '@plannotator/core'; import type { AIQuestion, AIResponse } from '../types'; import { generateId } from '../utils/generateId'; @@ -92,6 +92,70 @@ function createAbortError(message: string): Error { return err; } +/** + * Transport for the AI chat wire. Each method maps to one Plannotator + * `/api/ai/*` endpoint. The default reproduces today's fetches verbatim; + * a host (e.g. Workspaces) calls `setAITransport` once at startup to route + * AI traffic through its own backend. The SSE reader loop, epoch guards, and + * supersede-abort position in the hook are unaffected — only the wire is swapped. + */ +export interface AITransport { + /** POST /api/ai/session — create or fork a session. */ + session(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/query — send a message; returns the streaming SSE response. */ + query(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/abort — abort a session (supersede + standalone). Fire-and-forget. */ + abort(body: unknown): void; + /** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */ + permission(body: unknown): void; +} + +/** Default transport — Plannotator's local `/api/ai/*` fetches, verbatim. */ +const defaultAITransport: AITransport = { + session: (body, signal) => + fetch('/api/ai/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + query: (body, signal) => + fetch('/api/ai/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + abort: (body) => { + fetch('/api/ai/abort', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); + }, + permission: (body) => { + fetch('/api/ai/permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); + }, +}; + +// Module-level transport, stable identity. Defaults to Plannotator's behavior so +// the hook and its callers are unchanged. A host overrides it once at startup. +let aiTransport: AITransport = defaultAITransport; + +/** Override the AI chat transport. Call once at app startup. */ +export const setAITransport = (transport: AITransport): void => { + aiTransport = transport; +}; + +/** Reset to the default (Plannotator local `/api/ai/*`) transport. Mainly for tests. */ +export const resetAITransport = (): void => { + aiTransport = defaultAITransport; +}; + export function useAIChat({ context, providerId, @@ -131,17 +195,12 @@ export function useAIChat({ const requestId = ++createRequestRef.current; setIsCreatingSession(true); try { - const res = await fetch('/api/ai/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - context, - ...(providerId && { providerId }), - ...(model && { model }), - ...(reasoningEffort && { reasoningEffort }), - }), - signal, - }); + const res = await aiTransport.session({ + context, + ...(providerId && { providerId }), + ...(model && { model }), + ...(reasoningEffort && { reasoningEffort }), + }, signal); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Failed to create AI session' })); @@ -150,11 +209,7 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: data.sessionId }), - }).catch(() => {}); + aiTransport.abort({ sessionId: data.sessionId }); throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); @@ -210,16 +265,11 @@ export function useAIChat({ } const fullPrompt = buildPrompt(params); - const res = await fetch('/api/ai/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sid, - prompt: fullPrompt, - ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), - }), - signal: controller.signal, - }); + const res = await aiTransport.query({ + sessionId: sid, + prompt: fullPrompt, + ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), + }, controller.signal); if (!res.ok || !res.body) { const data = await res.json().catch(() => ({ error: 'Query failed' })); @@ -347,11 +397,7 @@ export function useAIChat({ } if (sessionIdRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionIdRef.current }), - }).catch(() => {}); + aiTransport.abort({ sessionId: sessionIdRef.current }); } }, []); @@ -362,15 +408,11 @@ export function useAIChat({ prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p) ); - fetch('/api/ai/permission', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionIdRef.current, - requestId, - allow, - }), - }).catch(() => {}); + aiTransport.permission({ + sessionId: sessionIdRef.current, + requestId, + allow, + }); }, [updatePermissions]); const resetSession = useCallback(() => { diff --git a/packages/ui/hooks/useAgents.ts b/packages/ui/hooks/useAgents.ts index 73667a8b3..79936584b 100644 --- a/packages/ui/hooks/useAgents.ts +++ b/packages/ui/hooks/useAgents.ts @@ -3,7 +3,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { getAgentSwitchSettings } from '../utils/agentSwitch'; export interface Agent { diff --git a/packages/ui/hooks/useAnnotationDraft.seam.test.tsx b/packages/ui/hooks/useAnnotationDraft.seam.test.tsx new file mode 100644 index 000000000..ff1ec18c9 --- /dev/null +++ b/packages/ui/hooks/useAnnotationDraft.seam.test.tsx @@ -0,0 +1,170 @@ +/** + * Seam test: DraftTransport override (setDraftTransport / resetDraftTransport). + * + * Contract: + * - fake.load() is called on mount (isApiMode: true, not shared). + * - fake.save() is called after scheduleDraftSave() fires (annotations non-empty). + * - resetDraftTransport() restores the default transport (does not call the fake). + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useAnnotationDraftModule from './useAnnotationDraft'; +import { AnnotationType, type Annotation } from '../types'; +import type { DraftTransport } from './useAnnotationDraft'; + +// Capture real function references at import time. +const setDraftTransport = useAnnotationDraftModule.setDraftTransport; +const resetDraftTransport = useAnnotationDraftModule.resetDraftTransport; +const useAnnotationDraft = useAnnotationDraftModule.useAnnotationDraft; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetDraftTransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +function makeFakeTransport(): { transport: DraftTransport; state: { loaded: number; saved: object[] } } { + const state = { loaded: 0, saved: [] as object[] }; + const transport: DraftTransport = { + load: async () => { + state.loaded++; + return { data: null, generation: null }; + }, + save: async (body: object) => { + state.saved.push(body); + }, + remove: async () => {}, + }; + return { transport, state }; +} + +const ANNOTATION: Annotation = { + id: 'ann-seam-1', + blockId: 'block-1', + startOffset: 0, + endOffset: 4, + type: AnnotationType.COMMENT, + text: 'seam check', + originalText: 'Test', + createdA: Date.now(), +}; + +type HookOptions = Parameters[0]; +type HookResult = ReturnType; + +function Harness({ opts, resultRef }: { opts: HookOptions; resultRef: { current: HookResult | null } }) { + resultRef.current = useAnnotationDraft(opts); + return null; +} + +async function mountHook(opts: HookOptions): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +const tick = (ms: number) => act(async () => new Promise((r) => setTimeout(r, ms))); + +describe('DraftTransport seam', () => { + test.skipIf(!hasDom)('fake.load() is called on mount when isApiMode is true', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + + const session = await mountHook({ + annotations: [], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + // Give the async load a moment to settle. + await tick(50); + + expect(state.loaded).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('fake.save() is called when scheduleDraftSave fires with annotations', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + + const session = await mountHook({ + annotations: [ANNOTATION], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + await tick(50); // let the mount-load settle + hasMountedRef = true + + await act(async () => { + session.result.current!.scheduleDraftSave(); + }); + + // scheduleDraftSave has a 500 ms debounce; wait for it to fire. + await tick(600); + + expect(state.saved.length).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetDraftTransport restores the default (does not call the fake)', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + resetDraftTransport(); + + // After reset, the default transport hits /api/draft — install a fetch spy. + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ found: false }), { status: 404 }); + }) as typeof fetch; + + const session = await mountHook({ + annotations: [], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + await tick(50); + + globalThis.fetch = realFetch; + + // Fake was NOT called; the default transport hit /api/draft. + expect(state.loaded).toBe(0); + expect(fetchCalls.some((u) => u.includes('/api/draft'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index e744d3693..f2ac27810 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -15,13 +15,92 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { SourceSaveCapability } from '@plannotator/shared/source-save'; +import type { SourceSaveCapability } from '@plannotator/core/source-save'; import type { Annotation, CodeAnnotation, ImageAttachment } from '../types'; import { fromShareable, parseShareableImages } from '../utils/sharing'; import type { ShareableAnnotation } from '../utils/sharing'; const DEBOUNCE_MS = 500; +/** + * Transport for persisting annotation/edit drafts. The default reproduces + * Plannotator's `/api/draft` server protocol verbatim. A host (e.g. Workspaces) + * may override it to persist drafts through its own backend. + * + * CONTRACT — a host overriding this MUST preserve the 3-party generation + * protocol or ghost drafts resurrect: + * - `save` must be best-effort on page close (the default uses `keepalive` + * with a retry-without-keepalive on failure, gated by a generation match). + * - `remove(generation)` is a generation-gated TOMBSTONE: the host's store + * must reject any later `save` whose `draftGeneration` is <= the deleted + * generation (delete-on-submit + tombstoning). The hook pre-increments + * `draftGeneration` and threads `getDraftGeneration()` out to the host, + * which sends it on approve/deny/feedback/exit so the server deletes the + * draft with the right generation. Drop this and a debounced save landing + * after submit re-creates a draft the server just deleted. + * - `load` returns the raw stored body (or null) plus the generation the + * store reports when there is NO draft (the default reads `draftGeneration` + * from the 404 body) so the client can resume past a tombstone. + */ +export interface DraftTransport { + /** GET the draft. `data` is the raw stored body (null if none). `generation` + is the store's reported generation when there is no draft (null otherwise). */ + load(): Promise<{ data: unknown | null; generation: number | null }>; + /** Persist the draft body. `keepalive` requests best-effort delivery on close. */ + save(body: object, opts: { keepalive: boolean }): Promise; + /** Generation-gated tombstone delete. */ + remove(generation: number, opts: { keepalive: boolean }): Promise; +} + +/** + * Default transport — Plannotator's `/api/draft` fetches, moved verbatim. + * `save` rejects on failure (the keepalive retry stays in the hook so its + * generation-match gate is preserved); `remove` always resolves. + */ +const defaultDraftTransport: DraftTransport = { + async load() { + const res = await fetch('/api/draft'); + const data = (await res.json().catch(() => null)) as unknown; + if (!res.ok) { + const generation = readDraftGeneration( + (data as MissingDraftData | null)?.draftGeneration, + ); + return { data: null, generation }; + } + return { data, generation: null }; + }, + save(body, { keepalive }) { + const payload = JSON.stringify(body); + const headers = { 'Content-Type': 'application/json' }; + return fetch('/api/draft', { method: 'POST', headers, body: payload, keepalive }).then( + () => {}, + ); + }, + remove(generation, { keepalive }) { + return fetch(`/api/draft?generation=${generation}`, { method: 'DELETE', keepalive }).then( + () => {}, + () => {}, + ); + }, +}; + +let draftTransport: DraftTransport = defaultDraftTransport; + +/** Read the active draft transport at call time (so a late override is honored). */ +export function getDraftTransport(): DraftTransport { + return draftTransport; +} + +/** Override the draft transport. Call once at app startup. */ +export function setDraftTransport(t: DraftTransport): void { + draftTransport = t; +} + +/** Reset to the default `/api/draft` transport. Mainly for tests. */ +export function resetDraftTransport(): void { + draftTransport = defaultDraftTransport; +} + type DraftSourceSaveCapability = Extract; export interface DraftEditedDocument { @@ -233,17 +312,12 @@ export function useAnnotationDraft({ useEffect(() => { if (!isApiMode || isSharedSession) return; - fetch('/api/draft') - .then(async res => { - const data = await res.json().catch(() => null) as DraftData | LegacyDraftData | MissingDraftData | null; - if (!res.ok) { - const generation = readDraftGeneration((data as MissingDraftData | null)?.draftGeneration); - if (generation !== null) { - draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); - } - return null; + getDraftTransport().load() + .then(({ data, generation }) => { + if (generation !== null) { + draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); } - return data; + return data as DraftData | LegacyDraftData | null; }) .then((data: DraftData | LegacyDraftData | null) => { if (!data) { @@ -335,7 +409,7 @@ export function useAnnotationDraft({ // explicitly threw away. const deletedGeneration = draftGenerationRef.current + 1; draftGenerationRef.current = deletedGeneration; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE', keepalive }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive }).catch(() => {}); return; } @@ -352,13 +426,14 @@ export function useAnnotationDraft({ ts: Date.now(), }; - const body = JSON.stringify(payload); - const headers = { 'Content-Type': 'application/json' }; - fetch('/api/draft', { method: 'POST', headers, body, keepalive }).catch(() => { + // The transport moves the POST behind the seam; the keepalive retry-on-failure + // gate stays in the hook verbatim so a host transport that resolves/rejects on + // failure still won't resurrect a superseded save. + getDraftTransport().save(payload, { keepalive }).catch(() => { // Chromium caps keepalive bodies (~64KB); retry without it. Completes // fine when the page was only backgrounded, best-effort on close. if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) { - fetch('/api/draft', { method: 'POST', headers, body }).catch(() => {}); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); } // Otherwise silent failure — draft is best-effort. }); @@ -441,9 +516,7 @@ export function useAnnotationDraft({ setDraftBanner(null); draftDataRef.current = null; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE' }).catch(() => { - // Silent failure - }); + getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/hooks/useArchive.ts b/packages/ui/hooks/useArchive.ts index 9bcf2e101..808bcaa9a 100644 --- a/packages/ui/hooks/useArchive.ts +++ b/packages/ui/hooks/useArchive.ts @@ -6,7 +6,7 @@ */ import { useState, useRef, useMemo, useCallback } from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; +import type { ArchivedPlan } from "@plannotator/core/storage-types"; import type { UseLinkedDocReturn } from "./useLinkedDoc"; import type { ViewerHandle } from "../components/Viewer"; import type { Annotation } from "../types"; diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index 0297ef9f8..6a01ec615 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { CodeAnnotation } from '../types'; +import { getDraftTransport } from './useAnnotationDraft'; const DEBOUNCE_MS = 500; @@ -72,17 +73,12 @@ export function useCodeAnnotationDraft({ useEffect(() => { if (!isApiMode) return; - fetch('/api/draft') - .then(async res => { - const data = await res.json().catch(() => null) as DraftData | MissingDraftData | null; - if (!res.ok) { - const generation = readDraftGeneration((data as MissingDraftData | null)?.draftGeneration); - if (generation !== null) { - draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); - } - return null; + getDraftTransport().load() + .then(({ data, generation }) => { + if (generation !== null) { + draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); } - return data; + return data as DraftData | null; }) .then((data: DraftData | null) => { const generation = readDraftGeneration(data?.draftGeneration); @@ -135,10 +131,9 @@ export function useCodeAnnotationDraft({ if (isEmpty) { // The user cleared everything (#948). Delete the draft with a generation // tombstone so it can't resurface on refresh and a late save can't revive - // it. Mirrors useAnnotationDraft.persistNow. - fetch(`/api/draft?generation=${draftGeneration}`, { method: 'DELETE' }).catch(() => { - // Silent failure - }); + // it. Mirrors useAnnotationDraft.persistNow — routed through the draft + // transport seam so a host backend tombstones its own stored draft too. + getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {}); return; } @@ -149,13 +144,7 @@ export function useCodeAnnotationDraft({ ts: Date.now(), }; - fetch('/api/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => { - // Silent failure - }); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); }, DEBOUNCE_MS); return () => { @@ -186,7 +175,7 @@ export function useCodeAnnotationDraft({ draftGenerationRef.current = deletedGeneration; setDraftBanner(null); draftDataRef.current = null; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE' }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/hooks/useExternalAnnotations.seam.test.tsx b/packages/ui/hooks/useExternalAnnotations.seam.test.tsx new file mode 100644 index 000000000..dcac481a4 --- /dev/null +++ b/packages/ui/hooks/useExternalAnnotations.seam.test.tsx @@ -0,0 +1,156 @@ +/** + * Seam test: ExternalAnnotationTransport override + * (setExternalAnnotationTransport / resetExternalAnnotationTransport). + * + * Contract: + * - On mount (enabled: true) → fake.subscribe() is called. + * - deleteExternalAnnotation() → fake.remove() called on the SAME transport + * instance (pins the already-landed split-transport fix: transportRef captures + * the transport once at mount, so CRUD and subscribe use the same backend). + * - resetExternalAnnotationTransport() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useExternalAnnotationsModule from './useExternalAnnotations'; +import type { ExternalAnnotationTransport } from './useExternalAnnotations'; + +// Capture real function references at import time. +const setExternalAnnotationTransport = useExternalAnnotationsModule.setExternalAnnotationTransport; +const resetExternalAnnotationTransport = useExternalAnnotationsModule.resetExternalAnnotationTransport; +const useExternalAnnotations = useExternalAnnotationsModule.useExternalAnnotations; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetExternalAnnotationTransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type TestAnnotation = { id: string; source?: string }; + +type HookResult = ReturnType>; + +function Harness({ + resultRef, + enabled = true, +}: { + resultRef: { current: HookResult | null }; + enabled?: boolean; +}) { + resultRef.current = useExternalAnnotations({ enabled }); + return null; +} + +async function mountHook(enabled = true): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +describe('ExternalAnnotationTransport seam', () => { + test.skipIf(!hasDom)('fake.subscribe() is called on mount when enabled', async () => { + const subscribeCalls: number[] = []; + + const fakeTransport: ExternalAnnotationTransport = { + subscribe: (_onEvent, _onError) => { + subscribeCalls.push(1); + return () => {}; + }, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fakeTransport); + + const session = await mountHook(); + + expect(subscribeCalls.length).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('fake.remove() is called on the SAME transport instance (split-transport fix)', async () => { + const removeIds: string[] = []; + + const fakeTransport: ExternalAnnotationTransport = { + subscribe: (_onEvent, _onError) => () => {}, + getSnapshot: async () => null, + add: async () => {}, + remove: async (id) => { removeIds.push(id); }, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fakeTransport); + + const session = await mountHook(); + + await act(async () => { + session.result.current!.deleteExternalAnnotation('annotation-id-1'); + }); + + expect(removeIds).toContain('annotation-id-1'); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetExternalAnnotationTransport restores the default (does not call the fake)', async () => { + const subscribeCalls: number[] = []; + const fake: ExternalAnnotationTransport = { + subscribe: () => { subscribeCalls.push(1); return () => {}; }, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fake); + resetExternalAnnotationTransport(); + + // Replace EventSource so the default SSE transport does not error. + class FakeEventSource { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) {} + close() {} + } + const prevES = (globalThis as Record).EventSource; + (globalThis as Record).EventSource = FakeEventSource; + + const session = await mountHook(); + + (globalThis as Record).EventSource = prevES; + + // The fake must NOT have been subscribed; the reset reinstalled the default. + expect(subscribeCalls).toHaveLength(0); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 467158a12..8d3d98385 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -19,6 +19,98 @@ const POLL_INTERVAL_MS = 500; const STREAM_URL = '/api/external-annotations/stream'; const SNAPSHOT_URL = '/api/external-annotations'; +/** + * Wire transport for external annotations. The hook owns the state machine + * (reducer, fallback-once SSE→polling, version-scoping, optimistic mutation, + * enabled gate); the transport owns ONLY the network/event wire. + * + * Default = Plannotator's SSE→polling behavior, verbatim. A host (Workspaces) + * can implement the same contract over its own backend (e.g. Durable Objects). + */ +export interface ExternalAnnotationTransport { + /** Open the live event stream. Returns an unsubscribe fn that tears it down. */ + subscribe( + onEvent: (event: ExternalAnnotationEvent) => void, + onError: () => void, + ): () => void; + /** Fetch a version-gated snapshot. Resolves null when there are no changes (304). */ + getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; + add(items: T[]): Promise; + remove(id: string): Promise; + update(id: string, fields: Partial): Promise; + clear(source?: string): Promise; +} + +/** + * Default transport — Plannotator's verbatim SSE→polling wire. + * EventSource on /api/external-annotations/stream; GET snapshot honoring 304→null; + * CRUD via DELETE/PATCH fetches (optimistic local mutation stays in the hook). + */ +function createDefaultTransport(): ExternalAnnotationTransport { + return { + subscribe(onEvent, onError) { + const es = new EventSource(STREAM_URL); + es.onmessage = (event) => { + try { + const parsed: ExternalAnnotationEvent = JSON.parse(event.data); + onEvent(parsed); + } catch { + // Ignore malformed events (e.g., heartbeat comments) + } + }; + es.onerror = () => { + onError(); + }; + return () => es.close(); + }, + async getSnapshot(since) { + const url = since > 0 ? `${SNAPSHOT_URL}?since=${since}` : SNAPSHOT_URL; + const res = await fetch(url); + if (res.status === 304) return null; // No changes + if (!res.ok) return null; + const data = await res.json(); + const annotations = Array.isArray(data.annotations) ? data.annotations : []; + const version = typeof data.version === 'number' ? data.version : 0; + return { annotations, version }; + }, + async add(items) { + await fetch(SNAPSHOT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ annotations: items }), + }); + }, + async remove(id) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, + async update(id, fields) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + }, + async clear(source) { + const qs = source ? `?source=${encodeURIComponent(source)}` : ''; + await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + }, + }; +} + +let externalAnnotationTransport: ExternalAnnotationTransport = createDefaultTransport(); + +/** Override the external-annotation wire transport. Call once at app startup. */ +export function setExternalAnnotationTransport( + transport: ExternalAnnotationTransport, +): void { + externalAnnotationTransport = transport; +} + +/** Reset to the default (Plannotator SSE→polling) transport. Mainly for tests. */ +export function resetExternalAnnotationTransport(): void { + externalAnnotationTransport = createDefaultTransport(); +} + interface UseExternalAnnotationsReturn { externalAnnotations: T[]; updateExternalAnnotation: (id: string, updates: Partial) => void; @@ -35,60 +127,75 @@ export function useExternalAnnotations | null>(null); const receivedSnapshotRef = useRef(false); + // Holds the active transport, shared by subscribe/poll AND the CRUD callbacks so + // reads and writes never split across backends. (Re-)captured from the module + // global when the effect runs on enable (below), so a host that installs a + // transport before enabling annotations is honored, not the stale default. + const transportRef = useRef(externalAnnotationTransport as ExternalAnnotationTransport); useEffect(() => { if (!enabled) return; let cancelled = false; - // --- SSE primary transport --- - const es = new EventSource(STREAM_URL); + // Reset fallback state on (re-)enable so a false→true toggle re-attempts SSE + // instead of inheriting a stale "already fell back" flag and silently stalling. + fallbackRef.current = false; + receivedSnapshotRef.current = false; - es.onmessage = (event) => { - if (cancelled) return; + // Capture the active transport at (re-)enable so a late host override is used. + transportRef.current = externalAnnotationTransport as ExternalAnnotationTransport; + const transport = transportRef.current; - try { - const parsed: ExternalAnnotationEvent = JSON.parse(event.data); - - switch (parsed.type) { - case 'snapshot': - receivedSnapshotRef.current = true; - setAnnotations(parsed.annotations); - break; - case 'add': - setAnnotations((prev) => [...prev, ...parsed.annotations]); - break; - case 'remove': - setAnnotations((prev) => - prev.filter((a) => !parsed.ids.includes(a.id)), - ); - break; - case 'clear': - setAnnotations((prev) => - parsed.source - ? prev.filter((a) => a.source !== parsed.source) - : [], - ); - break; - case 'update': - setAnnotations((prev) => - prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), - ); - break; - } - } catch { - // Ignore malformed events (e.g., heartbeat comments) + // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- + function applyEvent(parsed: ExternalAnnotationEvent) { + switch (parsed.type) { + case 'snapshot': + receivedSnapshotRef.current = true; + setAnnotations(parsed.annotations); + break; + case 'add': + setAnnotations((prev) => [...prev, ...parsed.annotations]); + break; + case 'remove': + setAnnotations((prev) => + prev.filter((a) => !parsed.ids.includes(a.id)), + ); + break; + case 'clear': + setAnnotations((prev) => + parsed.source + ? prev.filter((a) => a.source !== parsed.source) + : [], + ); + break; + case 'update': + setAnnotations((prev) => + prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), + ); + break; } - }; + } - es.onerror = () => { - // If we never received a snapshot, SSE isn't working — fall back to polling - if (!receivedSnapshotRef.current && !fallbackRef.current) { - fallbackRef.current = true; - es.close(); - startPolling(); - } - // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot - }; + // --- SSE primary transport --- + // `let` (not `const`) so onError firing synchronously during subscribe — a host + // transport may do this when its channel is immediately unavailable — reads a + // declared-but-undefined binding (no-op) instead of hitting the TDZ and throwing. + let unsubscribe: (() => void) | undefined; + unsubscribe = transport.subscribe( + (parsed) => { + if (cancelled) return; + applyEvent(parsed); + }, + () => { + // If we never received a snapshot, SSE isn't working — fall back to polling + if (!receivedSnapshotRef.current && !fallbackRef.current) { + fallbackRef.current = true; + unsubscribe?.(); + startPolling(); + } + // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot + }, + ); // --- Polling fallback --- function startPolling() { @@ -105,23 +212,10 @@ export function useExternalAnnotations 0 - ? `${SNAPSHOT_URL}?since=${versionRef.current}` - : SNAPSHOT_URL; - - const res = await fetch(url); - - if (res.status === 304) return; // No changes - if (!res.ok) return; - - const data = await res.json(); - if (Array.isArray(data.annotations)) { - setAnnotations(data.annotations); - } - if (typeof data.version === 'number') { - versionRef.current = data.version; - } + const snap = await transport.getSnapshot(versionRef.current); + if (snap === null) return; // No changes (304) or unavailable + setAnnotations(snap.annotations); + versionRef.current = snap.version; } catch { // Silent — next poll will retry } @@ -129,7 +223,7 @@ export function useExternalAnnotations { cancelled = true; - es.close(); + unsubscribe?.(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; @@ -141,10 +235,7 @@ export function useExternalAnnotations prev.filter((a) => a.id !== id)); try { - await fetch( - `${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, - { method: 'DELETE' }, - ); + await transportRef.current.remove(id); } catch { // SSE will reconcile on next event } @@ -156,8 +247,7 @@ export function useExternalAnnotations a.source !== source) : [], ); try { - const qs = source ? `?source=${encodeURIComponent(source)}` : ''; - await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + await transportRef.current.clear(source); } catch { // SSE will reconcile on next event } @@ -166,11 +256,7 @@ export function useExternalAnnotations) => { setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, ...updates } : a))); try { - await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); + await transportRef.current.update(id, updates); } catch { // SSE will reconcile on next event } diff --git a/packages/ui/hooks/useFileBrowser.seam.test.tsx b/packages/ui/hooks/useFileBrowser.seam.test.tsx new file mode 100644 index 000000000..daedad8c5 --- /dev/null +++ b/packages/ui/hooks/useFileBrowser.seam.test.tsx @@ -0,0 +1,136 @@ +/** + * Seam test: FileTreeBackend override (setFileTreeBackend / resetFileTreeBackend). + * + * Contract: after setFileTreeBackend(fake), useFileBrowser.fetchTree() calls + * fake.loadTree(dirPath) instead of /api/reference/files. + * resetFileTreeBackend() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useFileBrowserModule from './useFileBrowser'; + +// Capture real function references at import time. +const setFileTreeBackend = useFileBrowserModule.setFileTreeBackend; +const resetFileTreeBackend = useFileBrowserModule.resetFileTreeBackend; +const useFileBrowser = useFileBrowserModule.useFileBrowser; +type UseFileBrowserReturn = useFileBrowserModule.UseFileBrowserReturn; +type FileTreeBackend = useFileBrowserModule.FileTreeBackend; + +const hasDom = typeof document !== 'undefined'; +const realEventSource = (globalThis as Record).EventSource; + +afterEach(() => { + resetFileTreeBackend(); + if (hasDom) document.body.innerHTML = ''; + if (realEventSource !== undefined) { + (globalThis as Record).EventSource = realEventSource; + } else { + delete (globalThis as Record).EventSource; + } +}); + +function Harness({ resultRef }: { resultRef: { current: UseFileBrowserReturn | null } }) { + resultRef.current = useFileBrowser(); + return null; +} + +async function mountHook(): Promise<{ + result: { current: UseFileBrowserReturn | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: UseFileBrowserReturn | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +// Suppress EventSource so the watcher branch doesn't open a live stream +// and cause interference. +function suppressEventSource() { + (globalThis as Record).EventSource = undefined; +} + +describe('FileTreeBackend seam', () => { + test.skipIf(!hasDom)('fake.loadTree is called with the expected dirPath', async () => { + suppressEventSource(); + const loadTreeCalls: string[] = []; + const dirPath = '/repo/docs'; + const fakeBackend: FileTreeBackend = { + loadTree: async (path: string) => { + loadTreeCalls.push(path); + return new Response(JSON.stringify({ tree: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + loadVaultTree: async () => new Response(JSON.stringify({ tree: [] }), { status: 200 }), + watchTrees: () => undefined, + }; + + setFileTreeBackend(fakeBackend); + + const session = await mountHook(); + + await act(async () => { + await (session.result.current!.fetchTree(dirPath) as unknown as Promise); + }); + + expect(loadTreeCalls).toContain(dirPath); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetFileTreeBackend restores the default (does not call the fake)', async () => { + suppressEventSource(); + const fakeCalls: string[] = []; + const fakeBackend: FileTreeBackend = { + loadTree: async (path: string) => { fakeCalls.push(path); return new Response('{}', { status: 200 }); }, + loadVaultTree: async () => new Response('{}', { status: 200 }), + watchTrees: () => undefined, + }; + + setFileTreeBackend(fakeBackend); + resetFileTreeBackend(); + + // After reset, the default backend calls fetch(/api/reference/files...). + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ tree: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + + const session = await mountHook(); + + await act(async () => { + await (session.result.current!.fetchTree('/some/dir') as unknown as Promise); + }); + + globalThis.fetch = realFetch; + + // The fake was NOT consulted; the default backend hit /api/reference/files. + expect(fakeCalls).toHaveLength(0); + expect(fetchCalls.some((u) => u.includes('/api/reference/files'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 563b38ad4..eb74ecdd1 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -9,7 +9,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import type { VaultNode } from "../types"; -import type { WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; +import type { WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; export interface DirState { path: string; @@ -82,6 +82,101 @@ function remapWorkspaceStatusForDir( }; } +/** + * File-tree backend. Defaults to Plannotator's HTTP endpoints (generic files, + * Obsidian vault, and the SSE live-watch stream) so Plannotator is unchanged. A + * host (e.g. Workspaces) calls setFileTreeBackend once at startup to source the + * tree from its own transport instead. + */ +export interface FileTreeBackend { + /** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, error? }. */ + loadTree(dirPath: string): Promise; + /** Load an Obsidian vault tree. Resolves to a Response whose JSON is { tree, error? }. */ + loadVaultTree(vaultPath: string): Promise; + /** + * Begin live-watching the given directory paths. `onChange(path)` is invoked + * (already debounced/deduped) whenever a watched tree should be re-fetched. + * Returns a cleanup function. Returning undefined means no watcher started. + */ + watchTrees(paths: string[], onChange: (path: string) => void): (() => void) | undefined; +} + +const defaultFileTreeBackend: FileTreeBackend = { + loadTree(dirPath) { + return fetch(`/api/reference/files?dirPath=${encodeURIComponent(dirPath)}`); + }, + loadVaultTree(vaultPath) { + return fetch(`/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}`); + }, + watchTrees(paths, onChange) { + if (typeof EventSource === "undefined") return undefined; + + const timers = new Map>(); + const readyPaths = new Set(); + const params = new URLSearchParams(); + for (const path of paths) params.append("dirPath", path); + const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); + const scheduleFetch = (path: string) => { + const existing = timers.get(path); + if (existing) clearTimeout(existing); + timers.set(path, setTimeout(() => { + timers.delete(path); + onChange(path); + }, 120)); + }; + const scheduleEventFetch = (dirPath: unknown) => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + scheduleFetch(dirPath); + return; + } + for (const path of paths) scheduleFetch(path); + }; + const hasSeenReady = (dirPath: unknown): boolean => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + if (readyPaths.has(dirPath)) return true; + readyPaths.add(dirPath); + return false; + } + + const hadAll = paths.every((path) => readyPaths.has(path)); + for (const path of paths) readyPaths.add(path); + return hadAll; + }; + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; + if (data.type === "ready") { + if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath); + return; + } + if (data.type !== "changed") return; + scheduleEventFetch(data.dirPath); + } catch { + return; + } + }; + + return () => { + for (const timer of timers.values()) clearTimeout(timer); + source.close(); + }; + }, +}; + +// Active backend. Defaults to Plannotator's HTTP endpoints so Plannotator is +// unchanged. A host calls setFileTreeBackend once at startup to override. +let fileTreeBackend: FileTreeBackend = defaultFileTreeBackend; + +/** Override the file-tree backend. Call once at app startup. */ +export function setFileTreeBackend(b: FileTreeBackend): void { + fileTreeBackend = b; +} + +/** Reset to the default (HTTP endpoint) backend. Mainly for tests. */ +export function resetFileTreeBackend(): void { + fileTreeBackend = defaultFileTreeBackend; +} + export function useFileBrowser(): UseFileBrowserReturn { const [dirs, setDirs] = useState([]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); @@ -113,9 +208,7 @@ export function useFileBrowser(): UseFileBrowserReturn { }); try { - const res = await fetch( - `/api/reference/files?dirPath=${encodeURIComponent(dirPath)}` - ); + const res = await fileTreeBackend.loadTree(dirPath); const data = await res.json(); if (!res.ok || data.error) { @@ -220,9 +313,7 @@ export function useFileBrowser(): UseFileBrowserReturn { }); try { - const res = await fetch( - `/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}` - ); + const res = await fileTreeBackend.loadVaultTree(vaultPath); const data = await res.json(); if (!res.ok || data.error) { @@ -287,58 +378,11 @@ export function useFileBrowser(): UseFileBrowserReturn { ); useEffect(() => { - if (!watchDirsKey || typeof EventSource === "undefined") return; - + if (!watchDirsKey) return; const paths = watchDirsKey.split("\n").filter(Boolean); - const timers = new Map>(); - const readyPaths = new Set(); - const params = new URLSearchParams(); - for (const path of paths) params.append("dirPath", path); - const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); - const scheduleFetch = (path: string) => { - const existing = timers.get(path); - if (existing) clearTimeout(existing); - timers.set(path, setTimeout(() => { - timers.delete(path); - fetchTreeRef.current(path, { quiet: true }); - }, 120)); - }; - const scheduleEventFetch = (dirPath: unknown) => { - if (typeof dirPath === "string" && paths.includes(dirPath)) { - scheduleFetch(dirPath); - return; - } - for (const path of paths) scheduleFetch(path); - }; - const hasSeenReady = (dirPath: unknown): boolean => { - if (typeof dirPath === "string" && paths.includes(dirPath)) { - if (readyPaths.has(dirPath)) return true; - readyPaths.add(dirPath); - return false; - } - - const hadAll = paths.every((path) => readyPaths.has(path)); - for (const path of paths) readyPaths.add(path); - return hadAll; - }; - source.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; - if (data.type === "ready") { - if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath); - return; - } - if (data.type !== "changed") return; - scheduleEventFetch(data.dirPath); - } catch { - return; - } - }; - - return () => { - for (const timer of timers.values()) clearTimeout(timer); - source.close(); - }; + return fileTreeBackend.watchTrees(paths, (path) => { + fetchTreeRef.current(path, { quiet: true }); + }); }, [watchDirsKey]); return { diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index ef2b63a75..38e739f55 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -10,7 +10,7 @@ import { useState, useCallback, useRef } from "react"; import type { Annotation, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; -import type { SourceSaveCapability } from "@plannotator/shared/source-save"; +import type { SourceSaveCapability } from "@plannotator/core/source-save"; export interface LinkedDocLoadData { markdown?: string; diff --git a/packages/ui/hooks/usePlanDiff.ts b/packages/ui/hooks/usePlanDiff.ts index b5632b24b..a569b6874 100644 --- a/packages/ui/hooks/usePlanDiff.ts +++ b/packages/ui/hooks/usePlanDiff.ts @@ -48,11 +48,51 @@ export interface UsePlanDiffReturn { fetchVersions: () => Promise; } +export interface PlanDiffFetchers { + /** Fetch a specific version's plan content. Default → GET /api/plan/version?v=N */ + fetchVersion?: (version: number) => Promise<{ plan: string; version: number }>; + /** Fetch the version list. Default → GET /api/plan/versions */ + fetchVersions?: () => Promise<{ + project: string; + slug: string; + versions: VersionEntry[]; + }>; +} + +const defaultFetchVersion = async ( + version: number +): Promise<{ plan: string; version: number }> => { + const res = await fetch(`/api/plan/version?v=${version}`); + if (!res.ok) { + throw new Error(`Failed to load version ${version}.`); + } + return (await res.json()) as { plan: string; version: number }; +}; + +const defaultFetchVersions = async (): Promise<{ + project: string; + slug: string; + versions: VersionEntry[]; +}> => { + const res = await fetch("/api/plan/versions"); + if (!res.ok) { + throw new Error("Failed to load versions."); + } + return (await res.json()) as { + project: string; + slug: string; + versions: VersionEntry[]; + }; +}; + export function usePlanDiff( currentPlan: string, initialPreviousPlan: string | null, - versionInfo: VersionInfo | null + versionInfo: VersionInfo | null, + fetchers?: PlanDiffFetchers ): UsePlanDiffReturn { + const fetchVersionImpl = fetchers?.fetchVersion ?? defaultFetchVersion; + const fetchVersionsImpl = fetchers?.fetchVersions ?? defaultFetchVersions; const [diffBasePlan, setDiffBasePlan] = useState( initialPreviousPlan ); @@ -95,12 +135,7 @@ export function usePlanDiff( setIsSelectingVersion(true); setFetchingVersion(version); try { - const res = await fetch(`/api/plan/version?v=${version}`); - if (!res.ok) { - alert(`Failed to load version ${version}.`); - return; - } - const data = (await res.json()) as { plan: string; version: number }; + const data = await fetchVersionImpl(version); setDiffBasePlan(data.plan); setDiffBaseVersion(version); } catch { @@ -110,26 +145,20 @@ export function usePlanDiff( setFetchingVersion(null); } }, - [] + [fetchVersionImpl] ); const fetchVersions = useCallback(async () => { setIsLoadingVersions(true); try { - const res = await fetch("/api/plan/versions"); - if (!res.ok) return; - const data = (await res.json()) as { - project: string; - slug: string; - versions: VersionEntry[]; - }; + const data = await fetchVersionsImpl(); setVersions(data.versions); } catch { // Failed to fetch versions } finally { setIsLoadingVersions(false); } - }, []); + }, [fetchVersionsImpl]); return { diffBaseVersion, diff --git a/packages/ui/hooks/useScrollViewport.ts b/packages/ui/hooks/useScrollViewport.ts index 8390a2146..a4f178976 100644 --- a/packages/ui/hooks/useScrollViewport.ts +++ b/packages/ui/hooks/useScrollViewport.ts @@ -1,16 +1,16 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, createElement, type ReactNode } from 'react'; /** * Provides the currently-active scroll viewport element to descendants. * - * When the app is wrapped in , the element that actually - * scrolls is the library's internal viewport div — not
. Any code that - * needs the scroll container (IntersectionObserver roots, scroll event - * listeners, scrollTo / getBoundingClientRect offsets) must consume this - * context instead of `document.querySelector('main')`. + * The element that actually scrolls is the host element rendered by + * (native scroll) — not
. Any code that needs the + * scroll container (IntersectionObserver roots, scroll event listeners, + * scrollTo / getBoundingClientRect offsets) must consume this context instead + * of `document.querySelector('main')`. * - * The value is `null` until the OverlayScrollbars instance has mounted and - * initialized. Consumers should handle that transient state. + * The value is `null` until the scroll element has mounted. Consumers should + * handle that transient state. */ export const ScrollViewportContext = createContext(null); @@ -18,3 +18,21 @@ export const ScrollViewportContext = createContext(null); export function useScrollViewport(): HTMLElement | null { return useContext(ScrollViewportContext); } + +/** + * Render-transparent provider for the active scroll viewport element. + * + * The host mounts this around its layout and feeds it the MAIN content's scroll + * element, so descendants — including a sidebar Table-of-Contents rendered + * inside it — resolve to the main viewport (not the sidebar's own scroll area). + * Ships with the package so consumers work without app-shell wiring. + */ +export function ScrollViewportProvider({ + viewport, + children, +}: { + viewport: HTMLElement | null; + children: ReactNode; +}) { + return createElement(ScrollViewportContext.Provider, { value: viewport }, children); +} diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 5b6c31be6..a6df5ca45 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { extractCandidateCodePaths } from "@plannotator/shared/extract-code-paths"; +import { extractCandidateCodePaths } from "@plannotator/core/extract-code-paths"; export type ValidationEntry = | { status: "found"; resolved: string } @@ -29,6 +29,7 @@ export type ValidatedMap = Map; export function useValidatedCodePaths( markdown: string, baseDir?: string, + disabled?: boolean, ): { validated: ValidatedMap; ready: boolean } { const [validated, setValidated] = useState(new Map()); const [ready, setReady] = useState(false); @@ -37,6 +38,14 @@ export function useValidatedCodePaths( setValidated(new Map()); setReady(false); + // Host opt-out (e.g. a backend with no /api/doc/exists). Default undefined + // for Plannotator => unchanged. When disabled we never probe and leave + // ready=false, so gateCodePath's no-validation fallback renders code links + // optimistically (clickable) instead of demoting them to plain text. + if (disabled) { + return; + } + const candidates = extractCandidateCodePaths(markdown); if (candidates.length === 0) { setReady(true); @@ -76,7 +85,7 @@ export function useValidatedCodePaths( return () => { cancelled = true; }; - }, [markdown, baseDir]); + }, [markdown, baseDir, disabled]); // Stable reference: only changes when validated/ready actually change. // Without memoization, the parent provider's value is a fresh object every diff --git a/packages/ui/package.json b/packages/ui/package.json index dfc4a9178..9f2668149 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.21.1", "type": "module", "exports": { "./components/*": "./components/*.tsx", @@ -9,6 +9,7 @@ "./components/core/*": "./components/core/*.tsx", "./components/goal-setup/*": "./components/goal-setup/*.tsx", "./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx", + "./components/html-viewer": "./components/html-viewer/index.ts", "./components/sidebar/*": "./components/sidebar/*.tsx", "./components/plan-diff/*": "./components/plan-diff/*.tsx", "./utils/*": "./utils/*.ts", @@ -16,9 +17,35 @@ "./hooks/*": "./hooks/*.ts", "./shortcuts": "./shortcuts/index.ts", "./config": "./config/index.ts", + "./configure": "./configure.ts", "./types": "./types.ts", - "./theme": "./theme.css" + "./theme": "./theme.css", + "./styles.css": "./styles.css" }, + "files": [ + "components", + "config", + "hooks", + "icons", + "lib", + "shortcuts", + "utils", + "assets", + "themes", + "sprite_package_additional", + "sprite_package_new", + "sprite_package_pulluphang", + "globals.d.ts", + "configure.ts", + "types.ts", + "theme.css", + "print.css", + "styles.css", + "plannotator.webp", + "!**/*.test.ts", + "!**/*.test.tsx", + "!test-setup" + ], "dependencies": { "@atomic-editor/editor": "^0.4.3", "@codemirror/autocomplete": "^6.20.3", @@ -38,9 +65,8 @@ "@pierre/diffs": "1.2.8", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", - "@plannotator/ai": "workspace:*", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "0.1.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -52,28 +78,39 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", + "tailwind-merge": "^3.6.0", + "unique-username-generator": "^1.5.1" + }, + "peerDependencies": { "react": "^19.2.3", "react-dom": "^19.2.3", - "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", - "unique-username-generator": "^1.5.1" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", + "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "typescript": "~5.8.2" + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.8.2", + "vite": "^6.2.0" }, "scripts": { - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json", + "build:css": "vite build --config vite.css.config.ts && rm -f styles.js", + "prepack": "bun run build:css" } } diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css new file mode 100644 index 000000000..143bb1034 --- /dev/null +++ b/packages/ui/styles-entry.css @@ -0,0 +1,13 @@ +/* Fonts are NOT bundled here. The published styles.css ships only theme + component + styles; the consuming app loads the fonts (see README). The theme defines the + font-family tokens (--font-sans / --font-mono); the app makes those families + available. This keeps styles.css small and avoids shipping font binaries. */ +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@source "./components/**/*.tsx"; +@source "./hooks/**/*.ts"; +@source "./utils/**/*.ts"; + +@import "./theme.css"; diff --git a/packages/ui/theme.css b/packages/ui/theme.css index d1af09cc4..f9438749b 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -479,6 +479,120 @@ html:not(.transitions-ready) * { background-color: color-mix(in oklab, var(--destructive) 20%, var(--muted)); } +/* Annotation highlights */ +.annotation-highlight { + border-radius: 2px; + padding: 0 2px; + margin: 0 -2px; +} + +.annotation-highlight.deletion { + background: oklch(from var(--destructive) l c h / 0.35); + text-decoration: line-through; + text-decoration-color: var(--destructive); + text-decoration-thickness: 2px; +} + +.annotation-highlight.comment { + background: oklch(0.70 0.18 60 / 0.3); + border-bottom: 2px solid var(--accent); +} + +/* Light mode: softer highlights */ +.light .annotation-highlight.deletion { + background: oklch(0.65 0.22 25 / 0.2); +} + +.light .annotation-highlight.comment { + background: oklch(0.70 0.20 60 / 0.15); +} + +.annotation-highlight.focused { + background: oklch(from var(--focus-highlight) l c h / 0.45) !important; + box-shadow: 0 0 8px oklch(from var(--focus-highlight) l c h / 0.4); + border-bottom: 2px solid var(--focus-highlight); + filter: none; +} + +.light .annotation-highlight.focused { + background: oklch(0.70 0.22 200 / 0.3) !important; + box-shadow: 0 0 6px oklch(0.60 0.20 200 / 0.3); +} + +.annotation-highlight:hover { + filter: brightness(1.2); + cursor: pointer; +} + +/* ======================================== + Plan Diff Styles + ======================================== */ + +/* Clean diff view - added content */ +.plan-diff-added { + border-left: 3px solid var(--success); + background: oklch(from var(--success) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-added { + background: oklch(from var(--success) l c h / 0.06); +} + +/* Clean diff view - removed content */ +.plan-diff-removed { + border-left: 3px solid var(--destructive); + background: oklch(from var(--destructive) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-removed { + background: oklch(from var(--destructive) l c h / 0.06); +} + +/* Clean diff view - modified content (mix of additions and deletions in one + block, rendered inline via word-level diff). + Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events + — the whole block matters, so a loud fill is the right signal. Modify is + a WORD-scope event — the words matter, and the inline red-struck / + green-highlighted word markers already grab attention. A block-level fill + would compete with that inline work; an amber gutter on a normal + background says "look inside, the change is in the text" while staying + consistent with the green/red/yellow diff convention. */ +.plan-diff-modified { + border-left: 3px solid oklch(from var(--warning) l c h / 0.75); + background: transparent; + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} + +/* Clean diff view - unchanged (dimmed) */ +.plan-diff-unchanged { + /* handled via opacity in component */ +} + +/* Raw diff view - line styles */ +.plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.15); + color: var(--success); +} +.plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.15); + color: var(--destructive); + opacity: 0.75; + text-decoration: line-through; + text-decoration-color: oklch(from var(--destructive) l c h / 0.4); +} +.light .plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.12); +} +.light .plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.12); +} + /* Raw HTML blocks (CommonMark Type 6) rendered via dangerouslySetInnerHTML. Minimal typography so
,
, nested lists etc. inherit sensible spacing without fighting the surrounding prose styles. */ diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 683f100a6..63df81fa6 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,8 +18,9 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared": ["../shared/index.ts"], - "@plannotator/shared/*": ["../shared/*"] + "@plannotator/shared/*": ["../shared/*"], + "@plannotator/core": ["../core/index.ts"], + "@plannotator/core/*": ["../core/*"] } }, "include": [ diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 8f8f1c269..34483ed56 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -210,11 +210,11 @@ export interface VaultNode { children?: VaultNode[]; } -export type { EditorAnnotation } from '@plannotator/shared/types'; +export type { EditorAnnotation } from '@plannotator/core/types'; export type { ExternalAnnotationEvent, -} from '@plannotator/shared/external-annotation'; +} from '@plannotator/core/external-annotation'; export type { AgentJobInfo, @@ -222,4 +222,4 @@ export type { AgentJobStatus, AgentCapability, AgentCapabilities, -} from '@plannotator/shared/agent-jobs'; +} from '@plannotator/core/agent-jobs'; diff --git a/packages/ui/utils/aiProvider.ts b/packages/ui/utils/aiProvider.ts index 7d707adf5..83e8edfcd 100644 --- a/packages/ui/utils/aiProvider.ts +++ b/packages/ui/utils/aiProvider.ts @@ -7,7 +7,7 @@ */ import { storage } from './storage'; -import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/shared/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/core/agents'; const PROVIDER_KEY = 'plannotator-ai-provider'; const MODELS_KEY = 'plannotator-ai-models'; diff --git a/packages/ui/utils/annotateAgentTerminal.test.ts b/packages/ui/utils/annotateAgentTerminal.test.ts index 07dc4c7fd..95e7e323c 100644 --- a/packages/ui/utils/annotateAgentTerminal.test.ts +++ b/packages/ui/utils/annotateAgentTerminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { AgentTerminalAgent } from "@plannotator/shared/agent-terminal"; +import type { AgentTerminalAgent } from "@plannotator/core/agent-terminal"; import { resolveAnnotateAgentId } from "./annotateAgentTerminal"; const agents: AgentTerminalAgent[] = [ diff --git a/packages/ui/utils/annotateAgentTerminal.ts b/packages/ui/utils/annotateAgentTerminal.ts index 459f8e9c8..b9b3d4bec 100644 --- a/packages/ui/utils/annotateAgentTerminal.ts +++ b/packages/ui/utils/annotateAgentTerminal.ts @@ -1,4 +1,4 @@ -import type { AgentTerminalAgent } from "@plannotator/shared/agent-terminal"; +import type { AgentTerminalAgent } from "@plannotator/core/agent-terminal"; import { storage } from "./storage"; const DEFAULT_AGENT_KEY = "plannotator-annotate-agent-terminal-default"; diff --git a/packages/ui/utils/identity.seam.test.ts b/packages/ui/utils/identity.seam.test.ts new file mode 100644 index 000000000..e7a6673e8 --- /dev/null +++ b/packages/ui/utils/identity.seam.test.ts @@ -0,0 +1,77 @@ +/** + * Seam test: IdentityProvider override (setIdentityProvider / resetIdentityProvider). + * + * Contract: after setIdentityProvider(fake), getIdentity() delegates to the + * fake's getIdentity() — not to ConfigStore. resetIdentityProvider() restores + * the tater (ConfigStore-backed) provider. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as identityModule from './identity'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setIdentityProvider/resetIdentityProvider exports). +const setIdentityProvider = identityModule.setIdentityProvider; +const resetIdentityProvider = identityModule.resetIdentityProvider; +const getIdentity = identityModule.getIdentity; +const isCurrentUser = identityModule.isCurrentUser; + +afterEach(() => { + resetIdentityProvider(); +}); + +describe('IdentityProvider seam', () => { + it('routes getIdentity() through the fake provider', () => { + const calls: string[] = []; + const fake = { + getIdentity: () => { calls.push('getIdentity'); return 'workspace-user@example.com'; }, + isCurrentUser: (_author: string | undefined) => false, + }; + + setIdentityProvider(fake); + + const result = getIdentity(); + + expect(calls).toEqual(['getIdentity']); + expect(result).toBe('workspace-user@example.com'); + }); + + it('routes isCurrentUser() through the fake provider', () => { + const checked: Array = []; + const fake = { + getIdentity: () => 'user@example.com', + isCurrentUser: (author: string | undefined) => { + checked.push(author); + return author === 'user@example.com'; + }, + }; + + setIdentityProvider(fake); + + expect(isCurrentUser('user@example.com')).toBe(true); + expect(isCurrentUser('other@example.com')).toBe(false); + expect(checked).toEqual(['user@example.com', 'other@example.com']); + }); + + it('resetIdentityProvider restores the default (ConfigStore-backed) provider', () => { + const fake = { + getIdentity: () => 'should-not-appear', + isCurrentUser: () => false, + }; + + setIdentityProvider(fake); + resetIdentityProvider(); + + // After reset the default provider returns a non-empty tater name from ConfigStore, + // NOT the fake's sentinel value. + const result = getIdentity(); + expect(result).not.toBe('should-not-appear'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ui/utils/identity.ts b/packages/ui/utils/identity.ts index 8b73f7504..b67bb0ba5 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -14,10 +14,52 @@ import { configStore } from '../config'; import { generateIdentity } from './generateIdentity'; /** - * Get current identity from ConfigStore. + * Host-overridable identity provider. + * + * Default = today's tater behavior (ConfigStore-backed nickname + cookie match). + * A host (e.g. Workspaces) calls setIdentityProvider once at startup to stamp its + * logged-in user on comments and drive the `(me)` badge instead. Mirrors the + * swappable storage backend in ./storage.ts (StorageBackend/setStorageBackend). + */ +export interface IdentityProvider { + /** Display name stamped as `author` on new annotations. */ + getIdentity(): string; + /** Whether an annotation's `author` is the current user (drives the `(me)` badge). */ + isCurrentUser(author: string | undefined): boolean; +} + +/** + * Default provider: today's literal Plannotator behavior. + * `displayName` resolution stays in ConfigStore (server config > cookie > tater). + */ +const defaultIdentityProvider: IdentityProvider = { + getIdentity(): string { + return configStore.get('displayName'); + }, + isCurrentUser(author: string | undefined): boolean { + if (!author) return false; + return author === configStore.get('displayName'); + }, +}; + +// Active provider. Defaults to the tater identity so Plannotator is unchanged. +let identityProvider: IdentityProvider = defaultIdentityProvider; + +/** Override the identity provider. Call once at app startup. */ +export function setIdentityProvider(p: IdentityProvider): void { + identityProvider = p; +} + +/** Reset to the default (tater) provider. Mainly for tests. */ +export function resetIdentityProvider(): void { + identityProvider = defaultIdentityProvider; +} + +/** + * Get current identity. Delegates to the active provider (default = ConfigStore tater). */ export function getIdentity(): string { - return configStore.get('displayName'); + return identityProvider.getIdentity(); } /** @@ -42,9 +84,8 @@ export function regenerateIdentity(): string { } /** - * Check if an identity belongs to the current user. + * Check if an identity belongs to the current user. Delegates to the active provider. */ export function isCurrentUser(author: string | undefined): boolean { - if (!author) return false; - return author === configStore.get('displayName'); + return identityProvider.isCurrentUser(author); } diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index c8060707a..6f579bfc1 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,5 +1,5 @@ import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types'; -import { planDenyFeedback } from '@plannotator/shared/feedback-templates'; +import { planDenyFeedback } from '@plannotator/core/feedback-templates'; /** * Parsed YAML frontmatter as key-value pairs. diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 8b473452d..12317bc0a 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -9,8 +9,8 @@ */ import { Annotation, AnnotationType, type ImageAttachment } from '../types'; -import { compress, decompress } from '@plannotator/shared/compress'; -import { encrypt, decrypt } from '@plannotator/shared/crypto'; +import { compress, decompress } from '@plannotator/core/compress'; +import { encrypt, decrypt } from '@plannotator/core/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; diff --git a/packages/ui/utils/storage.seam.test.ts b/packages/ui/utils/storage.seam.test.ts new file mode 100644 index 000000000..2c49367a8 --- /dev/null +++ b/packages/ui/utils/storage.seam.test.ts @@ -0,0 +1,97 @@ +/** + * Seam test: StorageBackend override (setStorageBackend / resetStorageBackend). + * + * Contract: after setStorageBackend(fake), getItem/setItem route through the + * fake backend — NOT through document.cookie. resetStorageBackend() restores + * the cookie backend. + * + * No DOM required (the test never touches document.cookie). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as storageModule from './storage'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setStorageBackend/resetStorageBackend exports +// with no-op spies). +const setStorageBackend = storageModule.setStorageBackend; +const resetStorageBackend = storageModule.resetStorageBackend; +const getItem = storageModule.getItem; +const setItem = storageModule.setItem; +const removeItem = storageModule.removeItem; + +afterEach(() => { + resetStorageBackend(); +}); + +describe('StorageBackend seam', () => { + it('routes getItem through the installed fake backend', () => { + const store = new Map([['test-key', 'test-value']]); + const fake = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + }; + + setStorageBackend(fake); + + expect(getItem('test-key')).toBe('test-value'); + expect(getItem('missing-key')).toBeNull(); + }); + + it('routes setItem through the installed fake backend (not document.cookie)', () => { + const written: Array<{ key: string; value: string }> = []; + const read = new Map(); + const fake = { + getItem: (key: string) => read.get(key) ?? null, + setItem: (key: string, value: string) => { written.push({ key, value }); read.set(key, value); }, + removeItem: () => {}, + }; + + setStorageBackend(fake); + + setItem('seam-key', 'seam-value'); + + expect(written).toHaveLength(1); + expect(written[0]).toEqual({ key: 'seam-key', value: 'seam-value' }); + // Confirm read-back goes through the same fake + expect(getItem('seam-key')).toBe('seam-value'); + }); + + it('routes removeItem through the installed fake backend', () => { + const store = new Map([['k', 'v']]); + const removed: string[] = []; + const fake = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { removed.push(key); store.delete(key); }, + }; + + setStorageBackend(fake); + + removeItem('k'); + + expect(removed).toEqual(['k']); + expect(getItem('k')).toBeNull(); + }); + + it('resetStorageBackend restores the original behavior (does not use the fake)', () => { + const fake = { + getItem: (_: string) => 'should-not-see-this', + setItem: () => {}, + removeItem: () => {}, + }; + setStorageBackend(fake); + resetStorageBackend(); + + // After reset, reads go to cookies — in this env cookies return null for + // unknown keys (no cookie jar in the non-DOM test environment). + // The key point is that the fake is no longer consulted: if getItem + // returned 'should-not-see-this' the reset did not work. + const result = getItem('any-key'); + expect(result).not.toBe('should-not-see-this'); + }); +}); diff --git a/packages/ui/utils/storage.ts b/packages/ui/utils/storage.ts index 6c6e60ab1..a83b74cdf 100644 --- a/packages/ui/utils/storage.ts +++ b/packages/ui/utils/storage.ts @@ -9,39 +9,77 @@ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; +export interface StorageBackend { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * Default backend: cookies. + * Used instead of localStorage so settings persist across the random ports each + * hook invocation uses (cookies are scoped by domain, not port). + */ +const cookieBackend: StorageBackend = { + getItem(key) { + try { + const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + } catch (e) { + return null; + } + }, + setItem(key, value) { + try { + const encoded = encodeURIComponent(value); + document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`; + } catch (e) { + // Cookie not available + } + }, + removeItem(key) { + try { + document.cookie = `${key}=; path=/; max-age=0`; + } catch (e) { + // Cookie not available + } + }, +}; + +// Active backend. Defaults to cookies so Plannotator is unchanged. A host +// (e.g. Workspaces) calls setStorageBackend once at startup to persist settings +// through its own storage instead. +let backend: StorageBackend = cookieBackend; + +/** Override the storage backend. Call once at app startup. */ +export function setStorageBackend(b: StorageBackend): void { + backend = b; +} + +/** Reset to the default (cookie) backend. Mainly for tests. */ +export function resetStorageBackend(): void { + backend = cookieBackend; +} + /** - * Get a value from cookie storage + * Get a value from storage (default = cookies) */ export function getItem(key: string): string | null { - try { - const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`)); - return match ? decodeURIComponent(match[1]) : null; - } catch (e) { - return null; - } + return backend.getItem(key); } /** - * Set a value in cookie storage + * Set a value in storage (default = cookies) */ export function setItem(key: string, value: string): void { - try { - const encoded = encodeURIComponent(value); - document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`; - } catch (e) { - // Cookie not available - } + backend.setItem(key, value); } /** - * Remove a value from cookie storage + * Remove a value from storage (default = cookies) */ export function removeItem(key: string): void { - try { - document.cookie = `${key}=; path=/; max-age=0`; - } catch (e) { - // Cookie not available - } + backend.removeItem(key); } /** diff --git a/packages/editor/wideMode.test.ts b/packages/ui/utils/wideMode.test.ts similarity index 100% rename from packages/editor/wideMode.test.ts rename to packages/ui/utils/wideMode.test.ts diff --git a/packages/editor/wideMode.ts b/packages/ui/utils/wideMode.ts similarity index 100% rename from packages/editor/wideMode.ts rename to packages/ui/utils/wideMode.ts diff --git a/packages/ui/vite.css.config.ts b/packages/ui/vite.css.config.ts new file mode 100644 index 000000000..80300db2b --- /dev/null +++ b/packages/ui/vite.css.config.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + build: { + lib: { + entry: path.resolve(__dirname, 'styles-entry.css'), + formats: ['es'], + fileName: () => 'styles.js', + }, + outDir: '.', + cssCodeSplit: true, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +});