diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c7df2f7e..4e77cf5d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,9 +31,30 @@ jobs: - name: Run tests run: bun test - - name: Run file browser DOM tests + # The file-browser watcher test is timing-sensitive (a 120ms SSE debounce + # asserted with real timers). Run it in its own process — matching the + # historical config — so it isn't destabilized by sharing a process with + # the rest of the ui suite (heavy load intermittently starves the debounce + # timer and the reconnect refetch is missed). See useFileBrowser.test.tsx. + - name: Run file-browser DOM test (isolated) run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx + # Seam contracts + the remaining DOM-gated tests. Scoped to the DOM files + # (not the whole ui suite) to keep this process light. + - name: Run UI seam-contract + DOM tests + run: >- + DOM_TESTS=1 bun test + packages/ui/markdownEditorFidelity.test.tsx + packages/ui/annotationDraftPersistence.test.tsx + packages/ui/codeAnnotationDraftPersistence.test.tsx + packages/ui/components/InlineMarkdown.seam.test.tsx + packages/ui/components/ImageThumbnail.seam.test.tsx + packages/ui/hooks/useAnnotationHighlighter.test.tsx + packages/ui/hooks/useAnnotationDraft.seam.test.tsx + packages/ui/hooks/useExternalAnnotations.seam.test.tsx + packages/ui/hooks/useAIChat.seam.test.tsx + packages/ui/hooks/useFileBrowser.seam.test.tsx + pi-extension-ai-runtime-windows: # Exercises the Pi extension's Node/jiti server mirror on Windows with an # npm-style `pi` shim pair. This catches regressions where `where pi` diff --git a/.gitignore b/.gitignore index 999c66367..1cc98da2c 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 prepack — not committed) +packages/ui/styles.css +packages/ui/styles.js diff --git a/AGENTS.md b/AGENTS.md index f80726e43..4119c31a6 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) @@ -209,7 +212,7 @@ Ask AI's "changes under review" context for **code review** is generated by the ## 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/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md b/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md new file mode 100644 index 000000000..57dfa532a --- /dev/null +++ b/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md @@ -0,0 +1,58 @@ +# 005. Publish the document UI as `@plannotator/ui` + `@plannotator/core` + +Date: 2026-07-01 + +## Status + +Accepted + +## Context + +The commercial Workspaces app (Cloudflare Workers + D1 + R2 + Durable Objects, WorkOS identity, Yjs realtime) needs Plannotator's document UI: the markdown rendering engine, the annotation/comment machinery, the markdown editor, and the theme. Plannotator's open-source experience must not change while making that possible. + +Two approaches were on the table: + +1. **Copy/vendor** — fork the UI source into the Workspaces repo (an earlier from-scratch reimplementation of this UI was attempted, broke the app, and was reverted). +2. **Publish** — make the UI an installable package with host-override seams. + +Copy/vendor loses on two facts we've since measured: + +- **The reusable pieces sit deep in the package graph.** `packages/ui` imported freely from `@plannotator/shared`, which is Node-bound (fs/git/server). A copy would have to sever those imports by hand and re-sever them on every sync. +- **Main moves too fast for a fork.** During the packaging PR (#957) alone, the branch was rebased onto main **four times** (46 commits, then #979/#980, #981/#983, #878 + the 0.21.4 release). A vendored copy would already be four syncs behind; every UI feature landing on main (math rendering, parser hardening, PR-context work) would need manual porting. + +Seams beat surgery for the same reason: an override point whose default *is* today's code keeps one source of truth, whereas editing call sites per-consumer creates two. + +## Decision + +Publish two packages, keep one private, and decouple via seams — never rewrite: + +- **`@plannotator/core`** — browser-safe, zero-dependency pure utils + types, carved out of `@plannotator/shared` with `git mv`. CI typechecks it without `@types/node` so a `node:` import cannot sneak in. Published. +- **`@plannotator/ui`** — the React components, hooks, theme, and `configurePlannotatorUI()`. Depends on `core`. Published. +- **`@plannotator/shared`** — stays private; re-exports the moved core modules via one-line shims (`export * from '@plannotator/core/x'`), so none of Plannotator's ~99 internal import sites changed. + +**The seam pattern:** every place the UI touches a backend is a module-level singleton with a setter — `let impl = default; setX(); resetX(); getX()` — where the default reproduces Plannotator's literal behavior (`/api/*` over fetch). Plannotator passes nothing and stays byte-for-byte unchanged; a host calls `configurePlannotatorUI({...})` once at startup. Seams shipped: storage, identity (incl. `isEditable`), image resolving, uploads, doc previews, file tree, drafts, external annotations, AI transport, server sync. + +**Singleton, not a React Provider — with a named revisit condition:** module-level state is safe for client-side apps (one user per browser). It is wrong under SSR, where one server process renders for many users. Neither Plannotator nor Workspaces does SSR. **If SSR is ever added for this UI, that is the trigger to introduce a `` provider** and turn `configurePlannotatorUI` into a compatibility shim over it. Until then the complexity is not paid. + +**The law** (guardrails, enforced socially and by CI where possible): + +1. Don't reimplement the document UI from scratch — add a seam. +2. Every seam's default must reproduce today's Plannotator behavior. +3. `@plannotator/core` stays browser-safe and zero-dep — no `node:` imports. +4. Never delete working Plannotator code until a human confirms parity in the browser. + +**Publish model:** both packages version **in lockstep with the repo** (currently 0.21.4). They ship TypeScript source (consumers bundle it); `styles.css` is precompiled by `prepack`. `workspace:*` inter-deps must resolve to exact versions at publish time, so the flow is `bun pm pack` then `npm publish *.tgz --provenance --access public`, **`core` first, then `ui`**. First publish is manual from `main` after merge; a CI publish job is a follow-up. + +## Consequences + +- Workspaces installs the packages and plugs in its Cloudflare backend (localStorage settings, WorkOS identity with `isEditable() => false`, R2 uploads, D1 doc previews, DO-backed annotation transport) without forking. Its repo writes the matching consumer-side ADR. +- The published exports map is broad; **importable ≠ supported**. The supported-import allowlist and the unsupported "calls Plannotator's local server" list live in `packages/ui/HANDOFF.md`, alongside the annotation anchor schema and the editor/Yjs status. Restructuring exports was deliberately deferred. +- Known rough edges accepted for v1 (documented in HANDOFF.md): `AITransport`/`FileTreeBackend` leak `Response` objects; the markdown editor doesn't yet accept CM6 extensions (live collab lands via a later atomic PR threading `extensions?` through both wrapper layers after `@plannotator/markdown-editor` is imported into the monorepo). +- Every future "the host needs different behavior" request has one sanctioned shape: add an optional seam in a Plannotator PR whose default is today's behavior. +- Version lockstep means UI releases are repo releases; publishing stays owner-authorized. + +## References + +- PR #957 (`feat/pkg-document-ui`) +- `packages/ui/HANDOFF.md` — consumer handoff (seam catalog, supported imports, anchor schema) +- `packages/ui/README.md`, `packages/ui/CLAUDE.md` — the short version next to the code diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 89ae60d89..680c35fbf 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-context-live 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-diff html-assets html-assets-node url-to-markdown tour guide 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 commit-avatars commit-history; 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-context-live 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-diff 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 guide commit-avatars commit-history; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done @@ -56,9 +78,15 @@ for f in guide-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-app-server opencode-sdk command-path pi-sdk pi-sdk-node pi-events; do diff --git a/package.json b/package.json index ebe39daf1..44a635f35 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 packages/ui/tsconfig.strict-consumer.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 ab31f15ce..c48de6da2 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 f8fa0b3a5..d64e804ea 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -10,89 +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. Used as a fallback when the changeset can't be - * reproduced locally with a single VCS command. */ - patch: string; - /** The VCS diff type (e.g. "uncommitted", "branch", "merge-base"). When set - * to a git-reproducible type, the prompt tells the agent how to inspect the - * changes with git instead of pasting the whole diff. */ - diffType?: string; - /** The base branch/ref the diff is computed against (for branch/merge-base). */ - base?: 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..9463b4d9c --- /dev/null +++ b/packages/core/agent-jobs.ts @@ -0,0 +1,158 @@ +/** + * 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; + /** Pi's unified reasoning level (marker engines only), e.g. "minimal", "high". */ + thinking?: string; + /** 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; + /** + * Provider-discovered model catalog (currently only Cursor). Best-effort and + * account-specific — populated from the provider CLI at capability-detection + * time, empty when discovery fails or the CLI is unauthenticated. The UI + * drives its model picker from this instead of a hardcoded list. + */ + models?: { id: string; label: string }[]; +} + +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..a3130f72f --- /dev/null +++ b/packages/core/ai-context.ts @@ -0,0 +1,83 @@ +/** 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. Used as a fallback when the changeset can't be + * reproduced locally with a single VCS command. */ + patch: string; + /** The VCS diff type (e.g. "uncommitted", "branch", "merge-base"). When set + * to a git-reproducible type, the prompt tells the agent how to inspect the + * changes with git instead of pasting the whole diff. */ + diffType?: string; + /** The base branch/ref the diff is computed against (for branch/merge-base). */ + base?: 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..66372dd4d --- /dev/null +++ b/packages/core/config-types.ts @@ -0,0 +1,18 @@ +export type DefaultDiffType = 'since-base' | '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..039691993 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@plannotator/core", + "version": "0.22.0", + "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 5a91114d6..ad43bf33f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -48,7 +48,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 { @@ -101,7 +101,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'; import { annotateSidebarShortcuts, useAnnotateSidebarShortcuts, @@ -3935,7 +3935,7 @@ const App: React.FC = () => { )} {/* Main Content */} - +
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */} {taterMode && } @@ -4478,7 +4478,7 @@ const App: React.FC = () => { )}
-
+ {/* Code File Popout */} {codeFilePopout.popoutProps && ( diff --git a/packages/editor/index.css b/packages/editor/index.css index b235527b6..33e8eb81b 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -118,74 +118,7 @@ pre code.hljs .hljs-code { /* Annotation highlights moved to packages/ui/theme.css (shared with the code-review description annotations). */ -/* ======================================== - 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); -} +/* Plan Diff Styles also moved to packages/ui/theme.css (imported above). */ /* ======================================== Sidebar diff --git a/packages/shared/agent-jobs.ts b/packages/shared/agent-jobs.ts index 9463b4d9c..f14df7ec7 100644 --- a/packages/shared/agent-jobs.ts +++ b/packages/shared/agent-jobs.ts @@ -1,158 +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; - /** Pi's unified reasoning level (marker engines only), e.g. "minimal", "high". */ - thinking?: string; - /** 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; - /** - * Provider-discovered model catalog (currently only Cursor). Best-effort and - * account-specific — populated from the provider CLI at capability-detection - * time, empty when discovery fails or the CLI is unauthenticated. The UI - * drives its model picker from this instead of a hardcoded list. - */ - models?: { id: string; label: string }[]; -} - -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 03f3ee070..37d533edb 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 = 'since-base' | '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 be57c2a1c..4e4af9853 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -64,6 +64,7 @@ "./commit-avatars": "./commit-avatars.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 d2a8596bb..a513ff973 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/HANDOFF.md b/packages/ui/HANDOFF.md new file mode 100644 index 000000000..324c0be6d --- /dev/null +++ b/packages/ui/HANDOFF.md @@ -0,0 +1,297 @@ +# Handoff: reusing Plannotator's document UI in Workspaces + +This document is for the team building the commercial **Workspaces** app. It explains what this PR shipped, how the published packages are put together, and exactly how Workspaces plugs its own backend (storage, auth, realtime, AI) into the same document UI that Plannotator uses — without forking or rebuilding it. + +If you read nothing else, read **"The 60-second version"**, **"Supported imports"**, and **"The seam catalog"**. + +--- + +## The 60-second version + +- Plannotator's document UI (markdown rendering, theme, the annotation editor, settings, comments, file browser, plan diff, layout) is now two installable npm packages: **`@plannotator/ui`** (React components + hooks + theme) and **`@plannotator/core`** (pure utils + types, zero dependencies, browser-safe). +- Workspaces installs both, imports the components it wants, imports one stylesheet, loads fonts, and calls **`configurePlannotatorUI({ ... })` once at startup** to plug in its own backend. +- Every place the UI talks to a backend is an **optional seam**. Each seam has a default that reproduces today's Plannotator behavior (hitting `/api/*` over fetch). If Workspaces passes its own implementation, the UI uses that instead. If it passes nothing, it behaves like Plannotator. +- Plannotator itself is **unchanged** — it passes nothing and keeps using the defaults. This is the core constraint the whole design protects (see "The law"). + +--- + +## What this PR changed (inventory) + +**New package: `@plannotator/core`** — a browser-safe, zero-dependency package carved out of `@plannotator/shared`. It holds the pure utilities and types `ui` depends on, so `ui` can be installed without dragging in Plannotator's Node/server code. Modules were moved with `git mv` (not copied). CI typechecks it with no `@types/node` so a `node:` import can't sneak in. + +Core modules: `agents`, `agent-jobs`, `agent-terminal`, `browser-paths`, `code-file`, `compress`, `crypto`, `external-annotation`, `extract-code-paths`, `favicon`, `feedback-templates`, `goal-setup`, `open-in-apps`, `project`, `source-save`, plus extracted type files (`config-types`, `storage-types`, `workspace-status-types`, `ai-context`, `types`). + +**`@plannotator/shared` re-exports core via one-line shims** — e.g. `packages/shared/project.ts` is just `export * from '@plannotator/core/project';`. This is why none of Plannotator's ~99 internal import sites changed: they still import from `@plannotator/shared/*` and get the moved code transparently. + +**`@plannotator/ui` got the host-override seams** (the bulk of the diff) plus: +- `configure.ts` — the single front door, `configurePlannotatorUI()`. +- Each seam file gained a `setX`/`resetX` (or `get`) accessor and a default implementation. +- `*.seam.test.tsx` files — tests proving each seam defaults to Plannotator behavior and routes to a host override when set. +- Precompiled `styles.css` (~187KB, ~31KB gzip) built from `styles-entry.css` via `vite.css.config.ts`, so a consumer doesn't have to wire Tailwind to use the theme. Font binaries are **not** bundled (the consuming app owns fonts) — including KaTeX's math fonts: the publish build deliberately excludes `katex/dist/katex.min.css` (which would inline ~1.1MB of fonts). If you render math, see "Math rendering (KaTeX)" below. +- `wideMode.ts` moved from `packages/editor` into `ui/utils` (it was UI-layer state). + +Net: roughly 130 files changed, +5k/−2.4k vs main (regenerate with `git diff main --stat` for exact numbers — this line goes stale with every rebase). Most of the deletions are the `git mv` of core modules out of `shared`; most of the additions are seams + tests + the moved core package. + +--- + +## Architecture: three packages, one rule + +``` +@plannotator/core ← pure utils + types. zero deps. browser-safe (no node:). PUBLISHED. + ↑ +@plannotator/ui ← React components + hooks + theme + configure(). PUBLISHED. + depends on core (exact-version lockstep). + ↑ +@plannotator/shared ← Node/git/server logic. PRIVATE to the monorepo. + re-exports core's moved modules via shims so Plannotator is untouched. +``` + +- **Workspaces installs `@plannotator/ui` + `@plannotator/core`.** It never touches `shared` (that's Plannotator's server-side code). +- **No circular dependencies by construction**: `core` imports nothing, `ui` imports `core`, `shared` imports `core`. One direction only. +- **The packages ship TypeScript source, not compiled JS.** Workspaces' bundler compiles them (it's an internal consumer, and this keeps source-mapping and tree-shaking clean). That means Workspaces needs a TS/TSX-capable bundler — Vite + React 19 + Tailwind v4, with `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. Because your `tsc` type-checks the shipped `.ts`/`.tsx` with **your** compiler options (`skipLibCheck` only exempts `.d.ts`), the source is kept clean under `strict: true` — **CI-enforced**: `packages/ui/tsconfig.strict-consumer.json` type-checks the supported-import surface under full strict as part of the repo's `typecheck`, mirroring a standalone Vite consumer (which is also how it was originally verified). + +### The seam pattern (how an override works) + +Each seam is a module-level variable holding the current implementation, defaulting to Plannotator's behavior, with a setter: + +```ts +// utils/storage.ts (representative) +export interface StorageBackend { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +const cookieBackend: StorageBackend = { /* Plannotator's cookie reads/writes */ }; +let backend: StorageBackend = cookieBackend; // ← the default IS today's behavior + +export function setStorageBackend(b: StorageBackend) { backend = b; } // ← host override +export function resetStorageBackend() { backend = cookieBackend; } // ← tests restore default +``` + +Everything in the UI reads through `backend`. Plannotator never calls the setter, so it stays on cookies. Workspaces calls `setStorageBackend(itsOwnBackend)` once at startup (via `configurePlannotatorUI`) and the whole UI persists settings to Workspaces' store instead. + +**A note on this being module-level (a "singleton") and not a React Provider:** this is intentional and safe *for a client-side app*. Each user's browser runs its own copy of these variables; there's one logged-in user per browser; nothing is shared across users. The only setup where a module-level global is wrong is **server-side rendering** — one server process rendering for many concurrent users would let one user's render read another's identity. **Workspaces does not do SSR**, so this is a non-issue. If Workspaces ever adds SSR for this UI, that's the moment to revisit (the fix would be a React `` provider, and `configurePlannotatorUI` would become a thin compatibility shim over it). Until then, don't add that complexity. + +--- + +## The seam catalog + +Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted keeps Plannotator's default. The interfaces below are the real contracts as shipped. + +| Seam (config key) | Type | What it controls | Default behavior | +|---|---|---|---| +| `storageBackend` | `StorageBackend` | Where UI settings persist (identity, plan-save prefs, toggles) | Cookies | +| `identityProvider` | `IdentityProvider` | Who the current user is — stamps `author`, drives the `(me)` badge, and (via `isEditable()`) whether the Settings rename controls show | Reads `displayName` from ConfigStore (server > cookie > generated "tater" name); editable | +| `imageSrcResolver` | `(path, base?) => string` | Turns a stored image path/ref into a URL the browser can load | `/api/image?path=…` (http(s) URLs pass through unchanged) | +| `uploadTransport` | `UploadTransport` | Where pasted/attached images upload to | `POST /api/upload` (multipart), returns `{ path }` | +| `docPreviewFetcher` | `(path, base?) => Promise` | Hover/inline preview of a linked `.md` doc | `GET /api/doc` | +| `fileTreeBackend` | `FileTreeBackend` | The file/folder browser tree + live-watch | `GET /api/reference/files`, EventSource watch | +| `draftTransport` | `DraftTransport` | Auto-saved annotation drafts (survive a crash/reload) | `GET/POST/DELETE /api/draft` | +| `externalAnnotationTransport` | `ExternalAnnotationTransport` | Live/agent comments streamed into the doc | SSE `/api/external-annotations/stream` + polling snapshot + CRUD | +| `aiTransport` | `AITransport` | The "Ask AI" chat session/query/abort/permission | `POST /api/ai/{session,query,abort,permission}` | +| `serverSync` | `ServerSyncFn` | Push a settings change back to the server | No-op-ish (Plannotator's local sync) | +| `loadSettingsFromBackend` | `boolean` | After install, re-hydrate settings from your `storageBackend` | off | + +### Interface details worth knowing + +**`StorageBackend`** — must be **synchronous** (`getItem`/`setItem`/`removeItem` return immediately). If Workspaces' real store is async (KV, D1, a Durable Object), back this with an in-memory cache that you hydrate before mounting the UI, and write through asynchronously. That's also what `loadSettingsFromBackend: true` is for — it re-reads settings from your backend right after install, once it's in place. + +**No cookies on a configured host.** Settings resolution is **lazy** (first settings access, not module import). Plannotator's default backend is cookies (its servers run on random ports, so cookies are the only storage that survives across sessions there), and on first resolution the store seeds missing defaults — including a generated identity — into whatever backend is live. Because `configurePlannotatorUI` installs your `storageBackend` before anything reads a setting, a host that configures at startup gets **zero `plannotator-*` cookies** written to its origin, ever: all reads and seeding writes go to your backend. (Covered by `config/configStore.lazyInit.seam.test.ts`.) Only an unconfigured consumer — or one that reads settings before calling configure — falls back to cookie writes. + +> **⚠️ Ordering is load-bearing and nothing enforces it.** Call `configurePlannotatorUI` only **after** your settings hydration has completed. If you configure while the cache is still empty, `loadSettingsFromBackend` finds nothing, **seeds generated defaults into your backend via `setItem`** (including a freshly generated random display name), and nothing ever re-runs hydration — so the junk defaults can win over the user's real settings, and if your `setItem` writes through to durable storage they persist. The sync-and-prehydrated rule is a contract, not a runtime check. (For `localStorage`, which is already synchronous, none of this bites.) + +**`IdentityProvider`** — `getIdentity(): string` (display name), `isCurrentUser(author): boolean`, and optional `isEditable(): boolean` (default editable). For Workspaces this is your auth'd user. **Return `isEditable() => false`** for logged-in users: Workspaces stamps the author from the server-side account id and users can't rename themselves, so the UI must hide its rename/regenerate controls — otherwise a locally-chosen name diverges from the server-stamped author (the "split author" hazard). Two things to know from the Workspaces side: (1) the current `Me` projection (`GET /v1/me`) carries only `user_id` + `email` — **no display name** — so until the backend adds a name field, `getIdentity()` can only return the email or id; (2) free-text author names *are* accepted for anonymous commenters on open docs, so `isEditable()` may return `true` for that branch. + +**`UploadTransport`** — `upload(file: File): Promise<{ path: string; originalName? }>`. The default does Plannotator's `POST /api/upload` and returns the server path. For Workspaces, send the bytes to your asset API (`PUT /v1/workspaces/:wsId/assets/:assetPath`) and return the content-addressed URL (or an opaque ref) in `path`. Notes from the Workspaces asset layer: your API makes the **caller choose the asset path** and 409s if a document owns it, so your adapter — not the UI — owns path selection (namespace uploads, e.g. an `assets/` prefix); it enforces a **10 MiB cap + content-type allowlist**, so surface upload failures; and because asset URLs need **no signing** (content-addressed, served from the cookieless `tot.page` origin), `imageSrcResolver` can be a pass-through — returning a full URL in `path` renders directly (the default resolver passes http(s) URLs through). + +**`DraftTransport`** — `load()`, `save(body, { keepalive })`, `remove(generation, { keepalive })`. The generation-gated tombstone and keepalive retry logic stay inside the hook; you only provide the three transport calls. `keepalive: true` means "best-effort deliver this even though the page is closing" (maps to `fetch(..., { keepalive: true })` or `navigator.sendBeacon`). One non-obvious contract on `load()`: it returns `{ data, generation }`, where `generation` is the **deletion tombstone counter** for the no-draft case — Plannotator's server encodes it in the 404 body so a stale tab can't resurrect a deleted draft. If your backend tracks draft deletions, return the tombstone generation with `data: null`; if it doesn't, return `{ data, generation: null }` and the hook still works (you just lose stale-tab deletion protection). + +**`ExternalAnnotationTransport`** — `subscribe(onEvent, onError) => unsubscribe`, `getSnapshot(since) => { annotations, version } | null` (return `null` for "no changes", i.e. the 304 case), plus `add/remove/update/clear`. For Workspaces this is your realtime layer — a Durable Object WebSocket or SSE fanning out comment events. `T` extends `{ id: string; source?: string }`; if your annotation type adds fields, call `setExternalAnnotationTransport()` directly for full type safety (the `configure` front door pins the base type for ergonomics). + +**`AITransport`** and **`FileTreeBackend`** currently return `Response` objects** (the raw `fetch` response) rather than parsed domain types — `session/query` return `Promise`, `loadTree/loadVaultTree` return `Promise` whose JSON is a known shape. **This is a known rough edge** (see "Known rough edges"). To satisfy these today, Workspaces has to hand back something `Response`-shaped (status, `.json()`, and for `query`, an SSE body stream). It works, but it leaks the old HTTP contract. We deliberately left it as-is for the first cut (move-don't-rewrite); expect to clean it up in a v2 driven by what's actually painful when you wire it. + +--- + +## How Workspaces consumes it + +```bash +npm install @plannotator/ui @plannotator/core +``` + +```ts +// app entry, once at startup +import { configurePlannotatorUI } from "@plannotator/ui/configure"; +import "@plannotator/ui/styles.css"; + +// load fonts (the stylesheet references --font-sans / --font-mono but ships no binaries) +import "@fontsource-variable/inter"; +import "@fontsource-variable/geist-mono"; +// …or provide your own fonts and set --font-sans / --font-mono to match. + +configurePlannotatorUI({ + storageBackend, // your settings store (localStorage is already sync) + identityProvider, // your auth'd user (isEditable:false for logged-in users) + imageSrcResolver, // your asset URL scheme (pass-through for content-addressed URLs) + uploadTransport, // upload pasted images to your R2 asset API + docPreviewFetcher, // your doc store + fileTreeBackend, // your workspace file tree + realtime watch + draftTransport, // your draft store + externalAnnotationTransport, // adapt your Yjs/WebSocket realtime onto this + // aiTransport, // omit — Workspaces has no AI backend yet (stays default/off) + serverSync, // your settings push + loadSettingsFromBackend: true, // re-hydrate settings from storageBackend after install +}); +``` + +```ts +// then render the components you want +import { Viewer } from "@plannotator/ui/components/Viewer"; +``` + +A few component-specific behaviors (e.g. an "open this diff in the editor" action) are passed as **props** at the render site rather than through `configure` — those are local to one component, not app-global. + +### Mapping the seams to Workspaces' actual stack + +Grounded in a read of the Workspaces repo (`apps/app`, `apps/usercontent`, `apps/web`, the DocumentDO). The web app doesn't import this UI yet, so this is the greenfield wiring plan. + +| Seam | Workspaces backing | Effort | +|---|---|---| +| `storageBackend` | `window.localStorage` — already synchronous, matches the seam as-is. (Server-syncing prefs later is optional; not needed for the seam.) | trivial | +| `identityProvider` | Read the already-hydrated `me` from `SessionContext` (`GET /v1/me`). `getIdentity()` returns email/id (no name field yet), `isCurrentUser(a) = a === me.user_id`, `isEditable() => false` for logged-in users. | thin adapter | +| `imageSrcResolver` | Pass-through — asset URLs are content-addressed and need no signing. | trivial | +| `uploadTransport` | `PUT /v1/workspaces/:wsId/assets/:assetPath` → R2 (`AssetBytes` interface). Adapter owns asset-path selection. | new adapter | +| `docPreviewFetcher` | `GET /v1/workspaces/:wsId/documents/:docId` (D1 + git content store). | thin adapter | +| `fileTreeBackend` | `GET /v1/workspaces/:wsId/documents` (D1 doc list); live-watch via the DocumentDO. | thin adapter | +| `draftTransport` | KV or a per-doc Durable Object; `sendBeacon` for keepalive. | thin adapter | +| `externalAnnotationTransport` | **Transport kind differs** — Workspaces realtime is Yjs-over-WebSocket (DocumentDO), and comments are REST with no live push. Adapt comment events onto the DO awareness channel (or add an SSE endpoint). | biggest adapter | +| `aiTransport` | **No AI backend exists** in Workspaces. Leave at default/off until one is built. | new infra (later) | +| `serverSync` | A Worker endpoint that persists the settings delta. | thin adapter | + +**Backend follow-up (Workspaces side, not a UI change):** if you want readable author names instead of raw `user_…` ids in comments, the `Me`/annotation projections need to start carrying a display-name field (WorkOS has `first_name`/`last_name`; the current `Me` projection drops them). + +--- + +## Supported imports (the allowlist) + +The exports map is broad (wildcards over `./components/*`, `./hooks/*`, `./utils/*`), because Plannotator's own apps consume the package too. **Importable is not the same as supported for a host.** A number of exported modules still call Plannotator's local server directly, with no seam — they exist for Plannotator's plan-review/code-review apps and will break (failed fetches to `/api/*` on your origin) if a host renders them. (The wildcards aren't even literally complete: a handful of `.ts` files under `components/` don't resolve through the `*.tsx` pattern — e.g. `components/diagramLanguages`. Everything in the supported table below resolves; stay on the list.) + +We deliberately did **not** restructure the exports map in this PR (move-don't-rewrite); this list is the contract instead. + +### Supported — safe for a host that configures the seams + +| Import | Notes | +|---|---| +| `configure` (`configurePlannotatorUI`) | The front door. Also re-exports **every seam contract type** (`StorageBackend`, `IdentityProvider`, `UploadTransport`/`UploadResult`, `DraftTransport`, `ExternalAnnotationTransport`/`ExternalAnnotationEvent`, `AITransport`, `FileTreeBackend`/`VaultNode`, `ImageSrcResolver`, `DocPreviewFetcher`/`DocPreviewResult`, `ServerSyncFn`) so host adapters need one import. | +| `theme` / `styles.css` | Theme tokens + precompiled stylesheet. **Prefer `styles.css`.** The raw `theme` export still `@import`s KaTeX (re-acquiring the fonts `styles.css` deliberately excludes, as separate lazy files) and contains Tailwind v4 `@theme` at-rules, so it's inert without Tailwind processing. | +| `types` | `Annotation`, `Block`, `AnnotationType`, etc. | +| `utils/parser` (`parseMarkdownToBlocks`, `exportAnnotations`) | Pure — no backend. | +| `components/BlockRenderer` + the block components it renders (`TableBlock`, `HtmlBlock`, `Callout`, `MermaidBlock`, `MathBlock`, …) | Pure rendering. | +| `components/InlineMarkdown` | Code-file hover previews route through the `docPreviewFetcher` seam. | +| `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. | +| `components/MarkdownEditor` | Theme-bridging wrapper over `@plannotator/markdown-editor`. See the Yjs note below. | +| `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. | +| `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. | +| `components/ThemeProvider` | Color-mode context. | +| `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. | +| `components/AttachmentsButton` | Routes through `uploadTransport`. | +| Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. | +| `config` (`ConfigStore`) | Persists through `storageBackend`. | + +**AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code. + +### Unsupported — calls Plannotator's local server, no seam + +Don't import these in a host. Each hits hardcoded Plannotator endpoints: + +- `components/sidebar/VersionBrowser`, `hooks/usePlanDiff`, `components/plan-diff/*` — `/api/plan/version(s)` (Plannotator's version history; Workspaces builds its own versions UI anyway). +- `hooks/useArchive`, `components/sidebar/ArchiveBrowser` — `/api/archive/*`. +- `hooks/useAgents`, `hooks/useAgentJobs`, `components/AgentsTab` — `/api/agents/*`. +- `components/Settings`, `components/settings/HooksTab` — Plannotator-specific tabs (Obsidian vaults, hooks, integrations). +- `components/ExportModal`, `components/OpenInAppButton` — `/api/save-notes`, `/api/open-in` (Obsidian/Bear/editor integrations). +- `components/goal-setup/*` — Plannotator's goal-package scaffolding endpoints. +- `hooks/useEditorAnnotations` — `/api/editor-annotations` (VS Code extension only). +- `hooks/useLinkedDoc` — `/api/doc` directly (the `docPreviewFetcher` seam covers `InlineMarkdown`'s hover previews, **not** this full linked-doc overlay). +- `hooks/useValidatedCodePaths` — `/api/doc/exists` (this is what `Viewer`'s `disableCodePathValidation` turns off). +- `utils/sharing` — Plannotator's public paste service (share-URL feature). +- `hooks/useUpdateCheck`, `components/MenuVersionSection`, `components/PlanHeaderMenu` — Plannotator release checks. +- `utils/planAgentInstructions`, `utils/reviewAgentInstructions` — generate agent instructions that curl Plannotator's local API. + +If Workspaces ever wants one of these surfaces, the path is the same as everything else: add a seam to the module in a Plannotator PR, don't fork the component. + +### Math rendering (KaTeX): one-time setup if you render equations + +The renderer's `MathBlock` (and inline math) uses KaTeX. **KaTeX's stylesheet and its ~1.1MB of math fonts are deliberately NOT in the published `styles.css`** — bundling them would 9x the CSS for every page load, math or not. This is app-developer setup, done once; end users never touch it. Pick one: + +1. **Self-hosted (recommended for production):** copy `katex/dist/katex.min.css` + `katex/dist/fonts/` to your own asset origin and add one ``. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. +2. **CDN tag:** `` in your HTML — pin `` to the `katex` version in `@plannotator/ui`'s package.json so CSS and the bundled KaTeX JS stay in step. Same lazy-font behavior; adds a third-party origin. +3. **Bundler import:** `import 'katex/dist/katex.min.css';` next to your `styles.css` import — your bundler ships the fonts as separate lazy-loaded files. With npm/bun this resolves out of the box (`katex` is a dependency of `@plannotator/ui` and gets hoisted); under pnpm's strict `node_modules`, add `katex` to your own dependencies to import it directly. + +If you skip all three and render math, equations appear as broken-looking raw HTML — that's the symptom to recognize. If you never render math, do nothing. + +--- + +## The annotation anchor schema (what you're storing) + +When a host persists annotations (your REST comment API), the anchor fields on `Annotation` are the de facto contract. Store them as opaque JSON and round-trip them unchanged — but you should know what they are and when they go stale. + +From `@plannotator/ui/types`: + +```ts +interface Annotation { + // ... + originalText: string; // the exact text that was selected + startMeta?: { parentTagName: string; parentIndex: number; textOffset: number }; + endMeta?: { parentTagName: string; parentIndex: number; textOffset: number }; + mathTargets?: Array<{ blockId: string; tex: string; displayMode: boolean }>; // math selections only +} +``` + +`startMeta`/`endMeta` are **web-highlighter's DOM anchors**, captured against the *rendered* document: the tag name of the element containing the selection endpoint, the index of that element among all same-tag elements in the rendered DOM (document order), and the character offset within that element's text. They are positional, not content-addressed — they encode "the 14th `P`, character 32", not "this sentence". + +**Reattachment order** (in `useAnnotationHighlighter`, when a stored annotation is re-applied to a rendered document): + +1. **Math targets first** — if `mathTargets` is present, the matching KaTeX elements are located by `blockId` + exact `tex` string. +2. **Anchor restore** — `highlighter.fromStore(startMeta, endMeta, originalText, id)`. Works when the rendered DOM structure matches what it was at capture time. +3. **Text-search fallback** — if the anchors produce nothing (DOM changed shape), the hook searches the rendered text for an exact, whitespace-normalized occurrence of `originalText` and wraps it manually. This finds the **first** occurrence — if the selected text appears more than once, the highlight can attach to the wrong instance. +4. **Failure** — if the text is gone too, the hook logs a `console.warn` and applies **no highlight**. The annotation is *not* deleted: it still appears in the annotation panel and in exported feedback, it just has no visual anchor in the document body. + +**What this means for a host:** anchors survive re-renders of the *same* markdown. Once the document body is edited, the anchors are best-effort — `originalText` is the real recovery key, and an annotation whose text was deleted degrades to a panel-only comment. If you build "comments follow the text through edits" on top of this (Workspaces will, with live editing), plan to re-anchor server-side or via your Yjs layer; don't expect these DOM anchors to do it. + +**Honesty note:** the failure path (step 4) is exercised in real use but is **not covered by automated tests** — nothing in the suite asserts the stale-anchor behavior. Treat the described degradation as accurate-but-unverified-by-CI, and test it in your integration if you depend on it. + +--- + +## Known rough edges (and why they're fine for now) + +1. **`AITransport` / `FileTreeBackend` leak `Response`.** They return raw fetch `Response` objects instead of clean domain types (`{ sessionId }`, `AsyncIterable`, `{ tree, workspaceStatus }`). A reviewer correctly flagged this. We kept it deliberately: the goal of this PR was **move-don't-rewrite**, and reshaping these contracts is exactly the kind of redesign that's better driven by the real consumer (Workspaces) once you feel the pain. Plan a v2 pass on these two once you've wired them. + +2. **`InlineMarkdown.tsx` is large (~1k lines)** and now hosts the `docPreviewFetcher` seam inline. Cheap future cleanup: extract the doc-preview seam into its own module so the renderer shrinks. Not blocking. + +3. **Module-level singletons, not a Provider.** Covered above — safe because Workspaces is client-side, not SSR. Only revisit if SSR is added. + +4. **The markdown editor can't take live-collab extensions yet.** Live multi-user editing is a hard requirement for Workspaces (its ADR 0010), and the underlying editor (`@atomic-editor/editor`, CodeMirror 6) supports extensions — but **neither wrapper layer exposes them**: `@plannotator/markdown-editor`'s `MarkdownEditorProps` has no `extensions` prop, and `@plannotator/ui`'s `MarkdownEditor` wrapper therefore can't pass one. So today you cannot thread `y-codemirror.next` (or any CM6 extension) into the editor. **Do not treat live editing as available in this release.** The plan of record (updated now that the editor is forked as `github.com/plannotator/atomic-editor`, published as `@plannotator/atomic-editor`): one atomic change threading an optional `extensions?` prop through `@plannotator/atomic-editor` and `@plannotator/markdown-editor`, then a version bump here — no monorepo import needed. Single-user editing works today; the first Workspaces UI slice doesn't need live collab. + +None of these block adoption. They're the honest "here's what we'd polish next" list. + +--- + +## Publishing & versioning + +- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.22.0`). +- They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --provenance --access public`). Publish **`core` first, then `ui`**. +- `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS. +- There is **no CI publish job for these two packages yet** — first publish is manual from `main` after merge. (Wiring a CI publish job is a follow-up.) + +--- + +## The law (guardrails for anyone editing `@plannotator/ui`) + +These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted. + +1. **Don't reimplement the document UI from scratch.** Add a seam; don't rebuild. +2. **Every seam's default must reproduce today's Plannotator behavior.** Plannotator passes nothing and stays byte-for-byte unchanged. +3. **`@plannotator/core` is browser-safe and zero-dep — no `node:` imports.** CI enforces it. +4. **Never delete working Plannotator code until a human confirms parity in the browser.** + +See `packages/ui/README.md` and `packages/ui/AGENTS.md` (CLAUDE.md symlink) for the short version that lives next to the code. diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..676fbc989 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,60 @@ +# @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; if you'd rather run your own Tailwind over the package source, add `@source` globs for `@plannotator/ui`'s `components/`, `hooks/`, and `utils/` dirs in your own CSS — the package doesn't ship its build entry). +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. + The same policy covers math: KaTeX's stylesheet + fonts are deliberately not in `styles.css` — if you render math, load `katex/dist/katex.min.css` yourself (import, CDN tag, or self-hosted copy; see HANDOFF.md "Math rendering"). +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`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow. + +## 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/build-stubs/katex-css-stub.css b/packages/ui/build-stubs/katex-css-stub.css new file mode 100644 index 000000000..9d839812b --- /dev/null +++ b/packages/ui/build-stubs/katex-css-stub.css @@ -0,0 +1,7 @@ +/* Build-time stub. The published styles.css build (vite.css.config.ts) aliases + katex/dist/katex.min.css to this empty file: Vite's library mode inlines every + font a stylesheet references as a base64 data URI, and KaTeX's 60 math fonts + (~1.1MB) would balloon the published CSS ~9x. Hosts that render math import + KaTeX's stylesheet themselves (bundler import, CDN tag, or self-hosted copy — + see HANDOFF.md), which also gets them lazy font loading. Plannotator's own + apps import theme.css directly and are untouched by this alias. */ 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 de866069f..cc30a9ed8 100644 --- a/packages/ui/components/AgentsTab.tsx +++ b/packages/ui/components/AgentsTab.tsx @@ -14,7 +14,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, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons'; diff --git a/packages/ui/components/AttachmentsButton.tsx b/packages/ui/components/AttachmentsButton.tsx index 3d0f1876f..b74a67ffd 100644 --- a/packages/ui/components/AttachmentsButton.tsx +++ b/packages/ui/components/AttachmentsButton.tsx @@ -4,6 +4,7 @@ import { ImageThumbnail, getImageSrc } from './ImageThumbnail'; import { ImageAnnotator } from './ImageAnnotator'; import type { ImageAttachment } from '../types'; import { modKey } from '../utils/platform'; +import { getUploadTransport } from '../utils/upload'; /** * Derive a clean, human-readable name from an original filename. @@ -125,27 +126,26 @@ export const AttachmentsButton: React.FC = ({ const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { setUploading(true); try { - const formData = new FormData(); // Use annotated blob if drawings exist, otherwise original file - if (annotatorImage) { - const fileToUpload = hasDrawings + const fileToUpload = annotatorImage + ? (hasDrawings + ? new File([blob], 'annotated.png', { type: 'image/png' }) + : annotatorImage.file) + : editingImage + // Re-editing: always upload the new blob ? new File([blob], 'annotated.png', { type: 'image/png' }) - : annotatorImage.file; - formData.append('file', fileToUpload); - } else if (editingImage) { - // Re-editing: always upload the new blob - formData.append('file', new File([blob], 'annotated.png', { type: 'image/png' })); - } + : null; - const res = await fetch('/api/upload', { method: 'POST', body: formData }); - const data = await res.json(); - if (data.path) { - // If re-editing, remove old path first - if (editingImage) { - onRemove(editingImage.path); + if (fileToUpload) { + const data = await getUploadTransport().upload(fileToUpload); + if (data.path) { + // If re-editing, remove old path first + if (editingImage) { + onRemove(editingImage.path); + } + // Use the name from the annotator (user may have edited it) + onAdd({ path: data.path, name }); } - // Use the name from the annotator (user may have edited it) - onAdd({ path: data.path, name }); } } catch (err) { console.error('Upload failed:', err); 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 5c636cdb6..a30a1cde4 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,7 +1,7 @@ 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"; @@ -9,6 +9,37 @@ import type { ValidationEntry } from "../hooks/useValidatedCodePaths"; import { CodeFilePicker } from "./CodeFilePicker"; import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock"; +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) @@ -150,11 +181,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/Landing.tsx b/packages/ui/components/Landing.tsx index f01d7b160..ad40e123f 100644 --- a/packages/ui/components/Landing.tsx +++ b/packages/ui/components/Landing.tsx @@ -1,3 +1,4 @@ +/// import React from "react"; import logoImage from "../plannotator.webp"; import { ModeToggle } from "./ModeToggle"; diff --git a/packages/ui/components/LookAndFeelAnnouncementDialog.tsx b/packages/ui/components/LookAndFeelAnnouncementDialog.tsx index 9cec2d302..ff6182907 100644 --- a/packages/ui/components/LookAndFeelAnnouncementDialog.tsx +++ b/packages/ui/components/LookAndFeelAnnouncementDialog.tsx @@ -1,3 +1,4 @@ +/// import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { TextShimmer } from './TextShimmer'; 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 59c945ee3..43b7db709 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/ProviderIcons.tsx b/packages/ui/components/ProviderIcons.tsx index 77f9dabb8..aaf7503ba 100644 --- a/packages/ui/components/ProviderIcons.tsx +++ b/packages/ui/components/ProviderIcons.tsx @@ -1,3 +1,4 @@ +/// import type React from 'react'; import codexPng from '../assets/icon-codex.png'; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index c4bb5bd2d..e895acefa 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,11 +1,11 @@ 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, setReviewPanelView, setReviewDefaultDiffType } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; -import { getIdentity, regenerateIdentity, setCustomIdentity } from '../utils/identity'; +import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity'; import { GitUser } from '../icons/GitUser'; import { getObsidianSettings, @@ -91,6 +91,8 @@ interface SettingsProps { * (base ref unresolvable) — the Git tab shows a note that the Git-status * preference can't take effect in THIS repo. */ sinceBaseUnavailable?: boolean; + /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */ + onDetectObsidianVaults?: () => Promise; } // --- Review-mode Display tab (diff display options) --- @@ -649,7 +651,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -784,13 +786,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([])) @@ -885,6 +888,12 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange handleIdentitySave(gitUser); }; + // When a host owns identity (e.g. a logged-in user whose author is server-stamped), + // hide the rename/regenerate controls so a local name can't diverge from it. + // Default (Plannotator cookie identity) is editable, so this is true and the + // controls render exactly as before. + const identityEditable = isIdentityEditable(); + return ( <> )} - + {identityEditable && ( + + )} diff --git a/packages/ui/components/TaterSpritePullup.tsx b/packages/ui/components/TaterSpritePullup.tsx index bb1e045de..892761d56 100644 --- a/packages/ui/components/TaterSpritePullup.tsx +++ b/packages/ui/components/TaterSpritePullup.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_pulluphang/sprite.png'; diff --git a/packages/ui/components/TaterSpriteRunning.tsx b/packages/ui/components/TaterSpriteRunning.tsx index a50a50d90..4ab3e7efd 100644 --- a/packages/ui/components/TaterSpriteRunning.tsx +++ b/packages/ui/components/TaterSpriteRunning.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_additional/sprite.png'; diff --git a/packages/ui/components/TaterSpriteSitting.tsx b/packages/ui/components/TaterSpriteSitting.tsx index 89be0d28f..610f6e601 100644 --- a/packages/ui/components/TaterSpriteSitting.tsx +++ b/packages/ui/components/TaterSpriteSitting.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_new/sprite.png'; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 761fb713f..ce8fd9fe1 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, @@ -234,7 +238,7 @@ export const Viewer = forwardRef(({ const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false); const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null); const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); - const tableHoverTimeoutRef = useRef(null); + const tableHoverTimeoutRef = useRef | null>(null); const [popoutTable, setPopoutTable] = useState(null); // Viewer-specific comment popover state (global comments + code blocks) const [viewerCommentPopover, setViewerCommentPopover] = useState<{ @@ -250,7 +254,7 @@ export const Viewer = forwardRef(({ anchorEl: HTMLElement; codeBlock: { block: Block; element: HTMLElement }; } | null>(null); - const hoverTimeoutRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); const stickySentinelRef = useRef(null); const lastAutoScrolledHashRef = useRef(null); const [isStuck, setIsStuck] = useState(false); @@ -551,7 +555,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/components/types.d.ts b/packages/ui/components/types.d.ts index 76c6549de..b79978b5b 100644 --- a/packages/ui/components/types.d.ts +++ b/packages/ui/components/types.d.ts @@ -1,14 +1,7 @@ // Vite globals injected at build time declare const __APP_VERSION__: string; -// declare webp - -declare module "*.webp" { - const content: string; - export default content; -} - -declare module "*.png" { - const content: string; - export default content; -} +// Image asset module declarations live in ../globals.d.ts (the package's +// ambient-declaration home, /// 'd by each asset-importing +// component so consumer compilers load them too). Declaring them here as +// well would be a duplicate-identifier error under skipLibCheck: false. diff --git a/packages/ui/config/configStore.lazyInit.seam.test.ts b/packages/ui/config/configStore.lazyInit.seam.test.ts new file mode 100644 index 000000000..5b5aa90ed --- /dev/null +++ b/packages/ui/config/configStore.lazyInit.seam.test.ts @@ -0,0 +1,73 @@ +/** + * Lazy-resolution contract for ConfigStore. + * + * Tested on a FRESH instance (ConfigStoreForTest) rather than the module + * singleton: whether the singleton has already resolved depends on which test + * files ran first in the process, which made a singleton-based version of this + * test flaky in CI. The contract under test is the mechanism itself: + * construction resolves NOTHING — a host StorageBackend installed before the + * first settings access receives the initial resolution reads and the + * default-seeding writes. A configured host therefore never gets + * plannotator-* cookies written to its origin. + */ +import { describe, test, expect, afterAll, beforeEach } from 'bun:test'; +import { setStorageBackend, resetStorageBackend, type StorageBackend } from '../utils/storage'; +import { ConfigStoreForTest } from './configStore'; + +describe('configStore lazy resolution', () => { + const stored = new Map(); + let reads: string[] = []; + let writes: string[] = []; + const hostBackend: StorageBackend = { + getItem(key) { + reads.push(key); + return stored.get(key) ?? null; + }, + setItem(key, value) { + writes.push(key); + stored.set(key, value); + }, + removeItem(key) { + stored.delete(key); + }, + }; + + beforeEach(() => { + stored.clear(); + reads = []; + writes = []; + }); + + afterAll(() => { + resetStorageBackend(); + }); + + test('construction resolves nothing; first access resolves through the live backend', () => { + // Install the host backend, THEN construct. If the constructor resolved + // eagerly, the reads would happen here; the lazy contract says none do. + setStorageBackend(hostBackend); + const store = new ConfigStoreForTest(); + expect(reads.length).toBe(0); + expect(writes.length).toBe(0); + + const identity = store.get('displayName'); + + expect(identity.length).toBeGreaterThan(0); + // Resolution happened lazily, through the live (host) backend: + expect(reads.length).toBeGreaterThan(0); + // Missing defaults were seeded into the host backend — including the + // generated identity — not into document.cookie: + expect(writes).toContain('plannotator-identity'); + expect(stored.has('plannotator-identity')).toBe(true); + }); + + test('resolution runs once; later reads are served from memory', () => { + setStorageBackend(hostBackend); + const store = new ConfigStoreForTest(); + const first = store.get('displayName'); + const readsAfterLoad = reads.length; + const second = store.get('displayName'); + expect(second).toBe(first); + expect(reads.length).toBe(readsAfterLoad); + }); +}); diff --git a/packages/ui/config/configStore.seam.test.ts b/packages/ui/config/configStore.seam.test.ts new file mode 100644 index 000000000..0e2bb4538 --- /dev/null +++ b/packages/ui/config/configStore.seam.test.ts @@ -0,0 +1,112 @@ +/** + * 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('seeds the backend with resolved defaults for keys it lacks (first-run host store)', () => { + // First set a known value for 'displayName'. + configStore.set('displayName', 'prior-value'); + + // Install a fresh, empty host store (first-run host: nothing prefetched). + const store = new Map(); + const seedBackend = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + }; + + setStorageBackend(seedBackend); + configStore.loadFromBackend(); + + // In-memory value is preserved (not overwritten with undefined)... + expect(configStore.get('displayName')).toBe('prior-value'); + // ...AND the empty host backend is now seeded with the resolved default, so a + // reload reads it back from this backend instead of regenerating it. + expect(store.get('plannotator-identity')).toBe('prior-value'); + }); +}); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index d2c6afd4a..b1f523deb 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -29,6 +29,20 @@ function deepMerge(target: Record, source: Record) => void; + +/** Default = today's inline POST /api/config (best-effort). + keepalive lets the request outlive page teardown (pagehide flush). */ +const defaultServerSync: ServerSyncFn = (payload) => { + fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true, + }).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 @@ -41,10 +55,23 @@ class ConfigStore { private pendingServerWrites: Record = {}; private serverSyncTimer: ReturnType | null = null; private pagehideFlushRegistered = false; + private serverSync: ServerSyncFn = defaultServerSync; + private loaded = false; - constructor() { - // Eagerly resolve all settings from synchronous sources (cookie > default). - // The store is safe to read from the moment it's created. + /** + * Resolve all settings from the LIVE storage backend (cookie > default) on + * first use — deliberately not in the constructor. The singleton is created + * at module import, which for a host app is before configurePlannotatorUI() + * can install its StorageBackend; resolving eagerly there would write every + * missing default (including a generated identity) as cookies onto the host's + * origin. Deferring to first use means a host that configures at startup gets + * its own backend for the initial resolution too — no cookies are ever + * written on a configured host. Plannotator is unchanged: same resolution, + * same default-seeding writes, on first settings access instead of at import. + */ + private ensureLoaded(): void { + if (this.loaded) return; + this.loaded = true; for (const [name, def] of Object.entries(SETTINGS)) { const fromCookie = def.fromCookie(); const defaultVal = typeof def.defaultValue === 'function' @@ -59,6 +86,36 @@ 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 { + this.ensureLoaded(); + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } else { + // Seed the host backend with the resolved default. This matters when + // the store was already resolved BEFORE the host installed its + // StorageBackend (e.g. something read a setting pre-configure): those + // default-seeding writes went to the earlier backend, not this one. + // Without this, a fresh host store is never populated, so generated + // defaults (e.g. displayName) regenerate on every reload. In the normal + // configure-at-startup flow ensureLoaded() above already resolved + // through the host backend and this loop is a no-op re-read. + def.toCookie(this.values.get(name) as never); + } + } + this.notify(); + } + /** * Apply server config overrides. * Call once after fetching /api/plan or /api/diff. @@ -67,6 +124,7 @@ class ConfigStore { * by the constructor. Settings without a server value are left untouched. */ init(serverConfig?: Record): void { + this.ensureLoaded(); if (serverConfig) { for (const [name, def] of Object.entries(SETTINGS)) { if (def.serverKey && def.fromServer) { @@ -83,11 +141,13 @@ class ConfigStore { /** Get a resolved config value. Works outside React. */ get(key: K): SettingValue { + this.ensureLoaded(); return this.values.get(key) as SettingValue; } /** Set a config value. Writes cookie (sync), queues server write-back if applicable. */ set(key: K, value: SettingValue): void { + this.ensureLoaded(); const def = SETTINGS[key]; this.values.set(key, value); def.toCookie(value as never); @@ -106,6 +166,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(); @@ -132,17 +199,16 @@ class ConfigStore { this.serverSyncTimer = null; } if (Object.keys(this.pendingServerWrites).length === 0) return; - const payload = JSON.stringify(this.pendingServerWrites); + const payload = { ...this.pendingServerWrites }; this.pendingServerWrites = {}; - // keepalive lets the request outlive page teardown (pagehide flush). - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: payload, - keepalive: true, - }).catch(() => {}); // best-effort + this.serverSync(payload); } } export const configStore = new ConfigStore(); export type { SettingValue }; + +/** @internal Exported for tests only — lets the lazy-resolution contract be + verified on a fresh instance without depending on module-graph isolation + (the singleton may already be resolved by the time a given test file runs). */ +export { ConfigStore as ConfigStoreForTest }; diff --git a/packages/ui/config/index.ts b/packages/ui/config/index.ts index 458bb8a1d..4c7f9ecad 100644 --- a/packages/ui/config/index.ts +++ b/packages/ui/config/index.ts @@ -1,3 +1,4 @@ export { configStore } from './configStore'; +export type { ServerSyncFn } from './configStore'; export { useConfigValue } from './useConfig'; export { setReviewPanelView, setReviewDefaultDiffType, type ReviewDefaultDiffType } from './reviewView'; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index e2042c34f..7615962d7 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'; @@ -301,7 +301,10 @@ export const SETTINGS = { } }, }, -} satisfies Record>; + /* SettingDef, not : consumers compile this shipped source under + their own strictFunctionTypes, where a narrow `toCookie: (v: string) => void` + is contravariantly incompatible with `(value: unknown) => void`. */ +} satisfies Record>; export type SettingsMap = typeof SETTINGS; export type SettingName = keyof SettingsMap; diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts new file mode 100644 index 000000000..17d6d3cc7 --- /dev/null +++ b/packages/ui/configure.test.ts @@ -0,0 +1,257 @@ +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 upload from './utils/upload'; +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 { UploadTransport } from './utils/upload'; +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 realSetUploadTransport = upload.setUploadTransport; +const realResetUploadTransport = upload.resetUploadTransport; +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 setUploadTransport = mock((_: UploadTransport) => {}); +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 uploadTransport: UploadTransport = { upload: async () => ({ path: '/tmp/x.png' }) }; +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: async () => {}, + 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/upload', () => ({ + ...upload, + setUploadTransport, + resetUploadTransport: realResetUploadTransport, + })); + 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/upload', () => ({ + ...upload, + setUploadTransport: realSetUploadTransport, + resetUploadTransport: realResetUploadTransport, + })); + 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, + uploadTransport, + docPreviewFetcher, + fileTreeBackend, + identityProvider, + draftTransport, + externalAnnotationTransport, + aiTransport, + serverSync, + loadSettingsFromBackend: true, + }); + + expect(setImageSrcResolver).toHaveBeenCalledWith(imageSrcResolver); + expect(setDocPreviewFetcher).toHaveBeenCalledWith(docPreviewFetcher); + expect(setStorageBackend).toHaveBeenCalledWith(storageBackend); + expect(setUploadTransport).toHaveBeenCalledWith(uploadTransport); + 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, setUploadTransport, + 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..2d412c83d --- /dev/null +++ b/packages/ui/configure.ts @@ -0,0 +1,69 @@ +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher, type DocPreviewResult } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setUploadTransport, type UploadTransport, type UploadResult } from './utils/upload'; +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'; +import type { ExternalAnnotationEvent, VaultNode } from './types'; + +// One-stop type barrel: every seam contract a host implements is importable +// from this module, next to configurePlannotatorUI itself. +export type { + ImageSrcResolver, + DocPreviewFetcher, + DocPreviewResult, + StorageBackend, + UploadTransport, + UploadResult, + IdentityProvider, + FileTreeBackend, + VaultNode, + DraftTransport, + ExternalAnnotationTransport, + ExternalAnnotationEvent, + AITransport, + ServerSyncFn, +}; + +type ExternalAnnotationBase = { id: string; source?: string }; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + uploadTransport?: UploadTransport; + 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.uploadTransport) setUploadTransport(config.uploadTransport); + 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/globals.d.ts b/packages/ui/globals.d.ts index fedbc23de..d6264f74f 100644 --- a/packages/ui/globals.d.ts +++ b/packages/ui/globals.d.ts @@ -1,2 +1,14 @@ // Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.) declare module '*.css'; + +// Image asset imports (sprites, screenshots). Consumers compiling this shipped +// source need these ambient declarations too — each asset-importing component +// carries a /// to this file so any program that includes it gets them. +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.webp' { + const src: string; + export default src; +} 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..6466eaa01 --- /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: async () => {}, + 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: async () => { 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 1a6fd347f..ec85fc28f 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'; @@ -116,6 +116,83 @@ 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). Resolves once + the server acknowledges so a following query can await it (best-effort, never rejects). */ + abort(body: unknown): Promise; + /** 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; +}; + +/** + * Dispatch the abort transport without ever rejecting. A host transport whose + * `abort` throws — synchronously (deferred into `.then`) or asynchronously + * (`.catch`) — must not surface as an unhandled rejection, and the await site in + * ask() relies on this resolving. Single-sourced so every abort call site stays + * hardened. Reads `aiTransport` at call time so a late override is honored. + */ +function safeAbort(sessionId: string): Promise { + return Promise.resolve() + .then(() => aiTransport.abort({ sessionId })) + .catch(() => {}); +} + export function useAIChat({ context, providerId, @@ -158,17 +235,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' })); @@ -177,11 +249,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(() => {}); + void safeAbort(data.sessionId); // fire-and-forget cleanup of the orphaned session throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); @@ -200,12 +268,9 @@ export function useAIChat({ // it responds, so awaiting this guarantees a following query won't race it // into a session_busy error. Best-effort (never rejects). const postServerAbort = useCallback((): Promise => { - if (!sessionIdRef.current) return Promise.resolve(); - return fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionIdRef.current }), - }).catch(() => {}); + const sessionId = sessionIdRef.current; // capture the session being aborted now + if (!sessionId) return Promise.resolve(); + return safeAbort(sessionId); }, []); const ask = useCallback(async (params: AskAIParams) => { @@ -262,16 +327,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' })); @@ -413,15 +473,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/useAIProviderConfig.ts b/packages/ui/hooks/useAIProviderConfig.ts index 72f67e620..4ad3243d4 100644 --- a/packages/ui/hooks/useAIProviderConfig.ts +++ b/packages/ui/hooks/useAIProviderConfig.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { getAIProviderSettings, resolveAIProviderSelection, 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 71f58ec33..8d203a80c 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, Annotation, CommentAnnotation } from '../types'; +import { getDraftTransport } from './useAnnotationDraft'; const DEBOUNCE_MS = 500; @@ -78,17 +79,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); @@ -143,10 +139,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; } @@ -159,13 +154,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 () => { @@ -198,7 +187,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/useDismissOnOutsideAndEscape.ts b/packages/ui/hooks/useDismissOnOutsideAndEscape.ts index d0df6ed08..5c3eb5441 100644 --- a/packages/ui/hooks/useDismissOnOutsideAndEscape.ts +++ b/packages/ui/hooks/useDismissOnOutsideAndEscape.ts @@ -6,7 +6,7 @@ export function useDismissOnOutsideAndEscape({ onDismiss, }: { enabled: boolean; - ref: React.RefObject; + ref: React.RefObject; onDismiss: () => void; }) { useEffect(() => { 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..6e4fe865c 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -19,6 +19,100 @@ 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(); + // Skip (don't apply) on a malformed 200 — preserves existing annotations + // and the version cursor instead of clearing them, matching the + // pre-seam fetchSnapshot which only updated on well-formed payloads. + if (!Array.isArray(data.annotations) || typeof data.version !== 'number') return null; + return { annotations: data.annotations, version: data.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 +129,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 +214,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 +225,7 @@ export function useExternalAnnotations { cancelled = true; - es.close(); + unsubscribe?.(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; @@ -141,10 +237,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 +249,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 +258,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 9e907296d..5956d94f5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.22.0", "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": { "@plannotator/atomic-editor": "^0.5.0", "@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.2.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,7 +78,8 @@ "@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", "katex": "^0.16.47", "lucide-react": "^1.14.0", @@ -60,21 +87,31 @@ "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..5ae981072 --- /dev/null +++ b/packages/ui/styles-entry.css @@ -0,0 +1,16 @@ +/* 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. + That includes KaTeX: theme.css imports katex's stylesheet for Plannotator's own + apps, but the published build aliases it to an empty stub (vite.css.config.ts) — + hosts that render math load katex/dist/katex.min.css themselves (see HANDOFF.md). */ +@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 74ee9d30e..6928333cb 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -520,6 +520,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..4aad6668f 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,8 +18,8 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared": ["../shared/index.ts"], - "@plannotator/shared/*": ["../shared/*"] + "@plannotator/core": ["../core/index.ts"], + "@plannotator/core/*": ["../core/*"] } }, "include": [ diff --git a/packages/ui/tsconfig.strict-consumer.json b/packages/ui/tsconfig.strict-consumer.json new file mode 100644 index 000000000..c3166bf0a --- /dev/null +++ b/packages/ui/tsconfig.strict-consumer.json @@ -0,0 +1,42 @@ +{ + // Consumer-surface strict gate. External consumers (Workspaces) compile the + // shipped TS source with THEIR compiler options — typically strict: true — + // and skipLibCheck does not exempt .ts files. This config type-checks the + // supported-import surface (see HANDOFF.md "Supported imports") plus its + // transitive graph under full strict, mirroring a standalone Vite consumer. + // If this fails, a consumer's `tsc --noEmit` fails the same way. + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "files": [ + "configure.ts", + "types.ts", + "utils/parser.ts", + "components/BlockRenderer.tsx", + "components/InlineMarkdown.tsx", + "components/Viewer.tsx", + "components/MarkdownEditor.tsx", + "components/CommentPopover.tsx", + "components/AnnotationPanel.tsx", + "components/ThemeProvider.tsx", + "components/ImageThumbnail.tsx", + "components/AttachmentsButton.tsx", + "hooks/useAnnotationHighlighter.ts", + "hooks/useAnnotationDraft.ts", + "hooks/useCodeAnnotationDraft.ts", + "hooks/useExternalAnnotations.ts", + "hooks/useFileBrowser.ts", + "config/index.ts" + ] +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 48ca291ad..c17526be5 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -249,11 +249,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, @@ -261,4 +261,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 f0fba4c4c..dbfacc16f 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..a5de0f043 --- /dev/null +++ b/packages/ui/utils/identity.seam.test.ts @@ -0,0 +1,100 @@ +/** + * 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; +const isIdentityEditable = identityModule.isIdentityEditable; + +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('isIdentityEditable() reflects the fake provider (host owns identity ⇒ false)', () => { + setIdentityProvider({ + getIdentity: () => 'workspace-user', + isCurrentUser: () => false, + isEditable: () => false, + }); + expect(isIdentityEditable()).toBe(false); + }); + + it('isIdentityEditable() defaults to true when the provider omits isEditable', () => { + setIdentityProvider({ + getIdentity: () => 'legacy-host', + isCurrentUser: () => false, + }); + expect(isIdentityEditable()).toBe(true); + }); + + it('default (tater) provider is editable', () => { + resetIdentityProvider(); + expect(isIdentityEditable()).toBe(true); + }); + + 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..1cc895161 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -14,10 +14,64 @@ 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; + /** + * Whether the user may change their own display name from the Settings UI. + * Default (Plannotator's cookie identity) is editable. A host whose identity + * is owned by its auth system (e.g. a logged-in Workspaces user, whose author + * is the server-stamped account id) returns `false` so the rename/regenerate + * controls are hidden — preventing a locally-chosen name from diverging from + * the server-stamped author. Optional for backward-compat: absent ⇒ editable. + */ + isEditable?(): 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'); + }, + isEditable(): boolean { + return true; + }, +}; + +// 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 +96,17 @@ 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); +} + +/** + * Whether the current identity is user-editable (drives whether the Settings + * rename/regenerate controls are shown). Delegates to the active provider; + * defaults to editable when the provider doesn't implement `isEditable`. + */ +export function isIdentityEditable(): boolean { + return identityProvider.isEditable ? identityProvider.isEditable() : true; } diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index f61a80ab0..2305d1e2e 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/ui/utils/upload.seam.test.ts b/packages/ui/utils/upload.seam.test.ts new file mode 100644 index 000000000..3a921e454 --- /dev/null +++ b/packages/ui/utils/upload.seam.test.ts @@ -0,0 +1,72 @@ +/** + * Seam test: UploadTransport override (setUploadTransport / resetUploadTransport). + * + * Contract: after setUploadTransport(fake), getUploadTransport().upload routes + * through the fake — NOT through `fetch('/api/upload')`. resetUploadTransport() + * restores the default `/api/upload` transport. + * + * Function references are captured at module-load time so they remain valid even + * when configure.test.ts's mock.module() replaces the module exports later. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as uploadModule from './upload'; + +const setUploadTransport = uploadModule.setUploadTransport; +const resetUploadTransport = uploadModule.resetUploadTransport; +const getUploadTransport = uploadModule.getUploadTransport; + +afterEach(() => { + resetUploadTransport(); +}); + +function makeFile(name = 'pic.png'): File { + return new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' }); +} + +describe('UploadTransport seam', () => { + it('routes upload through the installed fake transport (not /api/upload)', async () => { + const uploaded: File[] = []; + setUploadTransport({ + async upload(file) { + uploaded.push(file); + return { path: 'r2://workspace/abc123', originalName: file.name }; + }, + }); + + const result = await getUploadTransport().upload(makeFile('login.png')); + + expect(uploaded).toHaveLength(1); + expect(uploaded[0].name).toBe('login.png'); + expect(result.path).toBe('r2://workspace/abc123'); + expect(result.originalName).toBe('login.png'); + }); + + it('resetUploadTransport restores the default (does not use the fake)', async () => { + let fakeCalled = false; + setUploadTransport({ + async upload() { + fakeCalled = true; + return { path: 'should-not-see-this' }; + }, + }); + resetUploadTransport(); + + // The default transport calls fetch('/api/upload'); stub fetch so the default + // path is exercised without a real server, and assert the fake is gone. + const originalFetch = globalThis.fetch; + let hitUrl: string | undefined; + // @ts-expect-error minimal fetch stub for the test + globalThis.fetch = async (url: string) => { + hitUrl = String(url); + return { json: async () => ({ path: '/tmp/upload-123.png', originalName: 'pic.png' }) }; + }; + try { + const result = await getUploadTransport().upload(makeFile()); + expect(fakeCalled).toBe(false); + expect(hitUrl).toBe('/api/upload'); + expect(result.path).toBe('/tmp/upload-123.png'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/ui/utils/upload.ts b/packages/ui/utils/upload.ts new file mode 100644 index 000000000..317d1ccb8 --- /dev/null +++ b/packages/ui/utils/upload.ts @@ -0,0 +1,56 @@ +/** + * Host-overridable image upload transport. + * + * Default = today's literal Plannotator behavior (POST /api/upload with the + * file as multipart form-data, response `{ path, originalName }`). A host + * (e.g. Workspaces) calls `setUploadTransport` once at startup to send the + * bytes to its own asset backend instead. Mirrors the swappable transports in + * ./storage.ts and ../hooks/useAnnotationDraft.ts. + */ + +export interface UploadResult { + /** + * Stored reference the UI round-trips and feeds to the image-src resolver. + * Plannotator returns the server file path. A host may return its own opaque + * ref or a fully-resolved URL (the default image-src resolver passes http(s) + * URLs through unchanged, so a returned URL renders directly). + */ + path: string; + /** Original file name, when the backend echoes it. */ + originalName?: string; +} + +export interface UploadTransport { + /** Upload one image file and resolve to its stored reference. */ + upload(file: File): Promise; +} + +/** Default transport — Plannotator's `/api/upload` multipart POST, verbatim. */ +const defaultUploadTransport: UploadTransport = { + async upload(file) { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const data = await res.json(); + return { path: data.path, originalName: data.originalName }; + }, +}; + +// Module-level transport, stable identity. Defaults to Plannotator's behavior so +// callers are unchanged. A host overrides it once at startup. +let uploadTransport: UploadTransport = defaultUploadTransport; + +/** Override how image attachments are uploaded. Call once at app startup. */ +export function setUploadTransport(t: UploadTransport): void { + uploadTransport = t; +} + +/** Reset to the default (Plannotator `/api/upload`) transport. Mainly for tests. */ +export function resetUploadTransport(): void { + uploadTransport = defaultUploadTransport; +} + +/** Read the active upload transport at call time (so a late override is honored). */ +export function getUploadTransport(): UploadTransport { + return uploadTransport; +} 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..c2d15c1c5 --- /dev/null +++ b/packages/ui/vite.css.config.ts @@ -0,0 +1,27 @@ +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, '.'), + // Keep KaTeX (and its ~1.1MB of math fonts, which lib mode would inline + // as data URIs) out of the published styles.css. Hosts that render math + // load katex/dist/katex.min.css themselves — see build-stubs/ and HANDOFF.md. + 'katex/dist/katex.min.css': path.resolve(__dirname, 'build-stubs/katex-css-stub.css'), + }, + }, + build: { + lib: { + entry: path.resolve(__dirname, 'styles-entry.css'), + formats: ['es'], + fileName: () => 'styles.js', + }, + outDir: '.', + cssCodeSplit: true, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +});